aboutsummaryrefslogtreecommitdiffstats
path: root/lib/data.lua
diff options
context:
space:
mode:
Diffstat (limited to 'lib/data.lua')
-rw-r--r--lib/data.lua397
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