diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-13 11:35:00 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-13 11:35:00 +0200 |
| commit | 3943e08dabe684b409a3ac99c9371fbeb5df5cdb (patch) | |
| tree | 7dcedbb2d64a04e59ab239a8c40c04f0f0cf29aa /lib/data.lua | |
| parent | b2fe3f493efc12dd300f0e2b2ede5660ee70c193 (diff) | |
| download | conky-theme-udt-3943e08dabe684b409a3ac99c9371fbeb5df5cdb.tar.gz conky-theme-udt-3943e08dabe684b409a3ac99c9371fbeb5df5cdb.zip | |
The quickshell island's performance panel now shows CPU, RAM,
temperatures, the GPU, disks and network, and warns from the status
pill when something runs hot, fills up, or the public IP drifts from
the pinned one. Two dashboards drawing the same numbers would drift
apart, so the four widgets go, with the disks and public IP samplers.
The island keeps writing ~/.cache/udt/pubip.txt in the same format.
The freed space is left empty for the user to rearrange.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'lib/data.lua')
| -rw-r--r-- | lib/data.lua | 397 |
1 files changed, 4 insertions, 393 deletions
diff --git a/lib/data.lua b/lib/data.lua index d02d0f7..804398e 100644 --- a/lib/data.lua +++ b/lib/data.lua @@ -1,54 +1,13 @@ --- Readers for /proc and /sys. +-- Parsers for the sampler caches. -- -- Every function takes the file *contents* as a string rather than reading the -- file itself, so the parsers are testable against fixtures without mocking the --- filesystem. The thin read-and-parse wrappers live at the bottom. +-- filesystem. local M = {} --- Aggregate CPU jiffies from /proc/stat. Returns total, idle. --- iowait counts as idle: a core waiting on disk is not doing work, and --- treating it as busy makes a disk-bound system look CPU-bound. -function M.cpu_times(stat) - local line = stat:match('^cpu%s+([^\n]+)') - if not line then return nil, nil end - local v = {} - for n in line:gmatch('%d+') do v[#v + 1] = tonumber(n) end - if #v < 5 then return nil, nil end - local total = 0 - for _, n in ipairs(v) do total = total + n end - return total, v[4] + v[5] -- idle + iowait -end - --- /proc/meminfo, values in kB as the file states them. -function M.mem_info(meminfo) - local function field(name) - return tonumber(meminfo:match(name .. ':%s+(%d+)')) - end - local total = field('MemTotal') - local available = field('MemAvailable') - if not (total and available) then return nil end - return { - total = total, - free = field('MemFree'), - available = available, - -- MemAvailable already excludes reclaimable cache, so this is the figure a - -- user recognises as "used", unlike total-free which counts cache. - used = total - available, - } -end - --- hwmon temp*_input is millidegrees C. Returns whole degrees, or nil. --- nil rather than 0 on failure: 0 C is a legitimate reading. -function M.millidegrees(s) - if not s then return nil end - local n = tonumber(s:match('^%s*(-?%d+)')) - if not n then return nil end - return math.floor(n / 1000 + 0.5) -end - --- Read a whole file, returning nil if it cannot be read. Used by the wrappers --- so a vanished sysfs path degrades to nil instead of raising. +-- Read a whole file, returning nil if it cannot be read, so a missing cache +-- degrades to nil instead of raising. function M.slurp(path) local f = io.open(path, 'r') if not f then return nil end @@ -57,192 +16,6 @@ function M.slurp(path) return s end --- Find a hwmon directory by the exact contents of its `name` file. --- --- Globbing by name, never by a fixed hwmon index: indices drift across kernel --- and hardware reorders, and a stale index silently reports a different chip. --- Carried over from the previous conky config, where this was the hard-won bit. --- --- The result is memoized per chip name, hits and misses alike. A chip's hwmon --- directory is stable for the process's life, and the draw hook calls this --- several times per frame: without the cache every sensor read popens grep, --- which is exactly the subprocess-per-draw the samplers exist to avoid. A miss --- is cached too, so an absent chip is not re-globbed on every frame. -local hwmon_cache = {} - -function M.hwmon_dir(name) - if type(name) ~= 'string' then return nil end - if hwmon_cache[name] == nil then - local p = io.popen('grep -lx ' .. ("%q"):format(name) - .. ' /sys/class/hwmon/hwmon*/name 2>/dev/null') - local dir = false - if p then - local hit = p:read('*l') - p:close() - if hit then dir = hit:match('^(.*)/name$') end - end - hwmon_cache[name] = dir - end - return hwmon_cache[name] or nil -end - --- A stateful CPU-load counter. --- --- Load is work done between two samples, so a single reading cannot produce a --- percentage. Each counter keeps its own previous sample, which also means a --- per-core counter is just another instance. -function M.new_cpu_counter() - return { - prev_total = nil, - prev_idle = nil, - -- Returns busy percent since the previous sample, or nil when there is no - -- usable delta (first call, or the counters did not advance). - sample = function(self, total, idle) - if not (total and idle) then return nil end - local pt, pi = self.prev_total, self.prev_idle - self.prev_total, self.prev_idle = total, idle - if not pt then return nil end - local dt = total - pt - if dt <= 0 then return nil end - local busy = (dt - (idle - pi)) / dt * 100 - -- Clamp: a counter reset or a suspend/resume can produce a nonsense - -- delta, and a bar drawn at -12% or 340% is worse than a clamped one. - if busy < 0 then busy = 0 elseif busy > 100 then busy = 100 end - return busy - end, - } -end - --- A stateful byte-rate counter, for an interface's rx/tx totals. --- --- Same shape as new_cpu_counter and for the same reason: the counters are --- cumulative, so one reading cannot produce a rate, and each direction needs --- its own previous sample. --- --- `fallback_dt` is the draw interval, used when two samples land in the same --- clock second. os.time() resolves to whole seconds and the dashboard draws --- every two, so equal timestamps are ordinary, not exceptional. -function M.new_rate_counter(fallback_dt) - return { - prev_bytes = nil, - prev_time = nil, - -- Returns bytes per second since the previous sample, or nil when there is - -- no usable delta (first call, or a nil reading from a vanished interface). - sample = function(self, bytes, now) - if type(bytes) ~= 'number' then return nil end - now = now or os.time() - local pb, pt = self.prev_bytes, self.prev_time - self.prev_bytes, self.prev_time = bytes, now - if not pb then return nil end - - local dt = now - pt - -- Same second, or a clock that went backwards over an NTP step. - if dt <= 0 then dt = fallback_dt or 2 end - - local db = bytes - pb - -- A negative delta is a 32-bit wrap or an interface reset. Zero, never - -- the huge positive the wrap arithmetic would imply: one bogus sample - -- sets the shared autoscale and flattens the whole window. - if db < 0 then db = 0 end - - return db / dt - end, - } -end - --- Per-core jiffies from /proc/stat, in file order, so entry N is core N. --- --- Separate from cpu_times() rather than a flag on it: the aggregate is a --- single pair and this is a list, and a function returning one or the other --- depending on an argument is worse than two functions. --- --- The leading 'cpu ' aggregate is excluded by requiring a digit after 'cpu', --- since counting it would draw an extra bar showing the average alongside the --- real cores. -function M.per_cpu_times(stat) - local out = {} - if type(stat) ~= 'string' then return out end - for line in stat:gmatch('[^\n]+') do - local nums = line:match('^cpu%d+%s+(.+)$') - if nums then - local v = {} - for n in nums:gmatch('%d+') do v[#v + 1] = tonumber(n) end - -- Needs at least user..iowait to compute a busy fraction; a shorter line - -- is truncated or from a kernel that reports differently, and a partial - -- sum would look plausible while being wrong. - if #v >= 5 then - local total = 0 - for _, n in ipairs(v) do total = total + n end - out[#out + 1] = { total = total, idle = v[4] + v[5] } - end - end - end - return out -end - --- A named hwmon sensor's value in whole degrees, or nil. --- --- Wraps hwmon_dir + slurp + millidegrees so a widget names the chip and the --- file rather than building paths. nil, never 0: zero degrees is a plausible --- reading and must not be indistinguishable from a missing sensor. -function M.sensor(chip, file) - if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end - local dir = M.hwmon_dir(chip) - if not dir then return nil end - return M.millidegrees(M.slurp(dir .. '/' .. file)) -end - --- A raw hwmon integer (fan RPM, power microwatts), or nil. Same lookup as --- sensor() without the millidegree conversion. -function M.sensor_raw(chip, file) - if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end - local dir = M.hwmon_dir(chip) - if not dir then return nil end - local s = M.slurp(dir .. '/' .. file) - if not s then return nil end - return tonumber(s:match('^%s*(-?%d+)')) -end - --- Parse `df -P -B1` output into a list of filesystems. --- --- The mountpoint is taken as the LAST field, not the sixth. df -P guarantees --- one line per filesystem with the mountpoint last, and an NFS device reads --- 'server:/export', so counting fields from the left is fragile in a way --- counting from the right is not. --- --- Sizes are bytes because the sampler passes -B1. Never parse `df -h` output --- here: the user's shell aliases df to df -h, so a sampler calling bare df --- would hand this function '1.6G' where it expects an integer. -function M.df_parse(text) - local out = {} - if type(text) ~= 'string' then return out end - for line in text:gmatch('[^\n]+') do - -- A data row ends in 'NN% /some/path'. The header ends in 'Mounted on', - -- which fails the percent match, so it is skipped without a special case. - local size, used, avail, pct, mount = - line:match('(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%%%s+(%S+)%s*$') - if size then - -- The device is kept so a caller can tell an NFS mount from a local one - -- and recover its server, which is how the disks card labels the two - -- shares without hardcoding an address in the repo. - local dev = line:match('^(%S+)') - out[#out + 1] = { - mount = mount, - dev = dev, - host = dev and dev:match('^([^/:]+):') or nil, - size = tonumber(size), - used = tonumber(used), - -- Read, not derived: size - used overstates free space by the - -- root-reserved blocks, which is tens of gigabytes on a large - -- filesystem and is not available to anyone but root. - avail = tonumber(avail), - pct = tonumber(pct), - } - end - end - return out -end - -- Human-readable size to bytes: '1.1G' -> 1181116006. -- -- Needed because du -sh is what the sampler runs (its output is also what the @@ -302,107 +75,6 @@ function M.kv_parse(text) return out end --- The CPU's marketing name, stripped to what identifies it. --- --- '/proc/cpuinfo' repeats the model name once per thread; the first is enough. --- The stripping is rules, not a table of known CPUs: a leading vendor word, --- the registered-trademark noise, and a trailing core count or clock, all of --- which are constant boilerplate that costs a third of the row on a card --- measured in pixels. --- --- AMD Ryzen 7 9700X 8-Core Processor -> Ryzen 7 9700X --- Intel(R) Core(TM) i7-8700K CPU @ 3.7GHz -> Core i7-8700K -function M.cpu_model(cpuinfo) - if type(cpuinfo) ~= 'string' then return nil end - local s = cpuinfo:match('model name%s*:%s*([^\n]+)') - if not s then return nil end - s = s:gsub('%(R%)', ''):gsub('%(TM%)', ''):gsub('%(tm%)', '') - -- The vendor word can sit behind a generation prefix ('11th Gen Intel Core - -- i7-1165G7'), so the prefix goes first and the vendor anchor is applied - -- after it rather than only at the very start. - s = s:gsub('^%s*%d+th Gen%s+', '') - s = s:gsub('^%s*AMD%s+', ''):gsub('^%s*Intel%s+', '') - s = s:gsub('%s+%d+%-Core Processor.*$', '') - -- The clock tail, with or without a literal 'CPU' before the '@'. A Xeon - -- reads 'E5-2680 v4 @ 2.40GHz' and a Tiger Lake 'i7-1165G7 @ 2.80GHz', so - -- requiring the CPU token leaves the clock on the card for everything but - -- the one form the fixture happens to carry. - -- The clock tail goes first, then the bare 'CPU' token wherever it sits: a - -- Xeon reads 'Xeon CPU E5-2680 v4 @ 2.40GHz', so the token is mid-string, - -- not trailing. - s = s:gsub('%s*@.*$', ''):gsub('%s+CPU%f[%A]', '') - s = s:gsub('%s+Processor%s*$', '') - s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '') - if s == '' then return nil end - return s -end - --- The motherboard, from the two world-readable DMI files. --- --- board_vendor and board_name are readable without privilege, unlike the --- serial fields in the same directory. The vendor's corporate suffix is --- dropped because every vendor has one and none of it identifies the board. --- --- A board reporting the DMI placeholder is treated as no board at all: --- 'To Be Filled By O.E.M.' on the dashboard is worse than a blank row. -local DMI_PLACEHOLDER = { - ['to be filled by o.e.m.'] = true, - ['system manufacturer'] = true, - ['default string'] = true, - ['unknown'] = true, - ['n/a'] = true, -} - -local function dmi_clean(s) - if type(s) ~= 'string' then return nil end - s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '') - if s == '' or DMI_PLACEHOLDER[s:lower()] then return nil end - return s -end - -function M.board_name(vendor, name) - vendor = dmi_clean(vendor) - name = dmi_clean(name) - if vendor then - -- Corporate boilerplate, longest first so 'Co., Ltd.' does not leave 'Co.' - vendor = vendor:gsub('%s+Technology Co%.,? Ltd%.?$', '') - vendor = vendor:gsub('%s+COMPUTER INC%.?$', '') - vendor = vendor:gsub('%s+Corporation$', ''):gsub('%s+Corp%.?$', '') - vendor = vendor:gsub('%s+Inc%.?$', ''):gsub('%s+INC%.?$', '') - vendor = vendor:gsub('%s+Co%.,?%s*Ltd%.?$', ''):gsub('%s+CO%.,?%s*LTD%.?$', '') - vendor = vendor:gsub('%s+GmbH$', ''):gsub('%s+LLC$', '') - vendor = vendor:gsub('%s+$', '') - if vendor == '' then vendor = nil end - end - if vendor and name then return vendor .. ' ' .. name end - return name or vendor -end - --- The IPv4 address from `ip -4 addr show <iface>` output. --- --- Split from the command that produces it so it can be tested against a --- fixture: every other parser in this file follows the same split, and an --- address is exactly the kind of value that must not be hardcoded in a test. -function M.iface_addr_parse(text) - if type(text) ~= 'string' then return nil end - return text:match('inet%s+(%d+%.%d+%.%d+%.%d+)') -end - --- The interface's IPv4 address, or nil when it has none. --- --- This shells out, which the draw hook otherwise never does, so the caller --- memoizes it: an address does not change without an event this dashboard --- does not watch. Retried while nil, because a bridge may not be up when --- conky starts. -function M.iface_addr(iface) - if type(iface) ~= 'string' then return nil end - local p = io.popen('ip -4 addr show ' .. ("%q"):format(iface) .. ' 2>/dev/null') - if not p then return nil end - local out = p:read('*a') - p:close() - return M.iface_addr_parse(out) -end - -- The calendar cache written by bin/calendar-sample.sh. -- -- Returns { today = { y, m, d }, colors = { <calendar> = <khal colour name> }, @@ -631,26 +303,6 @@ function M.immich_parse(text) return out end --- A sampling gate for counters that are read per draw. The dashboard draws --- every 0.1s while it is on screen, and a CPU or byte counter sampled that --- often gives coarse, flickering deltas, so those cards sample on this gate --- and redraw their last values in between. --- --- The returned function answers true at most once per `every` seconds, plus --- the clock it used: /proc/uptime, because os.time() counts whole seconds and --- a rate divided by a rounded interval is off by up to half. `now` is for the --- test. An unreadable clock opens the gate, which is the old per-draw behaviour. -function M.new_gate(every) - local last - return function(now) - now = now or tonumber((M.slurp('/proc/uptime') or ''):match('^(%S+)')) - if not now then return true, nil end - if last and now - last < every then return false, now end - last = now - return true, now - end -end - -- nvtable's plain table (`nvtable -C -E`), as bin/updates-sample.sh caches it: -- -- [personal] foo-bin 1.14.9 -> 1.14.9 !! ~ 1.14.10 @@ -690,45 +342,4 @@ function M.nvtable_parse(text) return seen and out or nil end --- VRAM in use on an xe device, in bytes, from DRM fdinfo: the same source --- gputop reads. Input is `grep -H` output, one 'path:key:<tab>value' line per --- field, so fields are grouped by the fd file they came from. --- --- One client (drm-client-id) is usually open on several fds, each repeating --- the client's full totals, so clients are counted once, not fds. Buffers --- shared between clients are still counted in each, as gputop does. -local UNIT = { B = 1, KiB = 1024, MiB = 1024 ^ 2, GiB = 1024 ^ 3 } - -function M.drm_vram_parse(text) - if type(text) ~= 'string' then return nil end - local fds = {} - for path, key, val in text:gmatch('([^\n]-):(drm%-[%w%-]+):%s*([^\n]*)') do - local fd = fds[path] or {} - fds[path] = fd - fd[key] = val - end - local seen, total = {}, nil - for _, fd in pairs(fds) do - local id = fd['drm-client-id'] - local n, unit = (fd['drm-resident-vram0'] or ''):match('^(%d+)%s*(%a*)') - if fd['drm-driver'] == 'xe' and id and n and not seen[id] then - seen[id] = true - total = (total or 0) + tonumber(n) * (UNIT[unit ~= '' and unit or 'B'] or 1) - end - end - return total -end - --- ponytail: a popen per call, ~10ms warm; callers gate it. Only fds of --- processes readable by conky's user are seen, so another user's GPU client --- (a system service) is not counted. -function M.drm_vram() - local p = io.popen("grep -sH -e '^drm-driver' -e '^drm-client-id'" - .. " -e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null") - if not p then return nil end - local out = p:read('*a') - p:close() - return M.drm_vram_parse(out) -end - return M |
