-- Readers for /proc and /sys. -- -- 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. 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. function M.slurp(path) local f = io.open(path, 'r') if not f then return nil end local s = f:read('*a') f:close() 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 -- 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, 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), 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 -- card displays), and 'biggest' has to be decided numerically: sorted as -- strings, '245M' beats '1.1G'. local SUFFIX = { K = 1024, M = 1024^2, G = 1024^3, T = 1024^4, P = 1024^5 } function M.human_bytes(s) if type(s) ~= 'string' then return nil end local n, suf = s:match('^%s*([%d%.]+)%s*([KMGTP]?)') n = tonumber(n) if not n then return nil end return n * (SUFFIX[suf] or 1) end -- Parse `du -sh` output: the first line is the total, the rest are children -- already sorted largest-first by the sampler. Returns nil when there is -- nothing usable, so the widget can draw its "no data" state. function M.du_parse(text) if type(text) ~= 'string' or text == '' then return nil end local lines = {} for line in text:gmatch('[^\n]+') do lines[#lines + 1] = line end if #lines == 0 then return nil end local function entry(line) local label, path = line:match('^(%S+)%s+(.+)$') if not label then return nil end return { label = label, bytes = M.human_bytes(label) or 0, name = path:match('([^/]+)/?$') or path, path = path } end local total = entry(lines[1]) if not total then return nil end local items = {} for i = 2, #lines do local e = entry(lines[i]) if e then items[#items + 1] = e end end return { total = total, items = items } end return M