diff options
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 |
