-- 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 -- 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 -- 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 -- Parse 'key value' lines into a table of strings. -- -- Values stay strings and are converted by the caller. The sampler writes -- epochs as integers and the widget decides whether an age reads as hours or -- days; a parser that guessed would have to know that. -- -- Only the first space separates, so a value keeps its own spaces: -- 'version Slackware 15.0+' yields 'Slackware 15.0+', not 'Slackware'. function M.kv_parse(text) local out = {} if type(text) ~= 'string' then return out end for line in text:gmatch('[^\n]+') do local k, v = line:match('^(%S+)%s+(.*)$') if k and v ~= '' then out[k] = v end end 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 ` 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 = { = }, -- events = { { y, m, d, start, finish, calendar, title }, ... } }, or nil when -- the cache is absent or carries no 'generated' key. nil means "the sampler -- never ran", which the card names; an empty events list means "nothing in the -- next week", which is a different and equally valid state. -- -- Dates arrive in khal's own `longdateformat`, whatever the user set it to, so -- the three numbers are pulled out by position and assigned by magnitude: the -- 4-digit group is the year, and of the remaining two the one that cannot be a -- month is the day. An unambiguous pair (03.04.2026) is read as day-first, -- matching khal's default and the European formats its config ships. This is a -- heuristic, and it is the honest one available: khal will not emit ISO. function M.calendar_parse(text) if type(text) ~= 'string' then return nil end local out = { colors = {}, events = {} } local generated = false for line in text:gmatch('[^\n]+') do local kind, rest = line:match('^(%S+)%s+(.*)$') if kind == 'generated' then generated = true elseif kind == 'today' then local y, m, d = rest:match('^(%d+)-(%d+)-(%d+)$') if y then out.today = { tonumber(y), tonumber(m), tonumber(d) } end elseif kind == 'color' then local name, colour = rest:match('^(%S+)%s+(.*)$') if name and colour ~= '' then out.colors[name] = colour end elseif kind == 'event' then -- Split on the first four pipes only: a title may contain one. local date, st, en, cal, title = rest:match('^([^|]*)|([^|]*)|([^|]*)|([^|]*)|(.*)$') local ev = date and M.calendar_date(date) if ev then out.events[#out.events + 1] = { y = ev[1], m = ev[2], d = ev[3], start = st ~= '' and st or nil, finish = en ~= '' and en or nil, calendar = cal ~= '' and cal or nil, title = title, } end end end if not generated then return nil end return out end -- Three numbers out of a formatted date, as { year, month, day }. -- See calendar_parse for why this is positional rather than a format string. function M.calendar_date(s) if type(s) ~= 'string' then return nil end local nums = {} for n in s:gmatch('%d+') do nums[#nums + 1] = n end if #nums < 3 then return nil end -- The year is the 4-digit group. A 2-digit year is not handled: khal's -- shipped formats all use %Y, and guessing a century from two digits would -- be a second heuristic stacked on the first. local yi for i = 1, 3 do if #nums[i] == 4 then yi = i break end end if not yi then return nil end local rest = {} for i = 1, 3 do if i ~= yi then rest[#rest + 1] = tonumber(nums[i]) end end local a, b = rest[1], rest[2] local day, month if a > 12 then day, month = a, b elseif b > 12 then month, day = a, b else day, month = a, b end -- ambiguous: day-first, as khal's defaults are if month < 1 or month > 12 or day < 1 or day > 31 then return nil end return { tonumber(nums[yi]), month, day } end -- Which days of a given month carry an event, and whose calendar owns each. -- Returns { [day] = }. First event of a day wins, so a day -- with two calendars takes the earlier one rather than blending into a colour -- that means neither. function M.calendar_month_days(events, year, month) local out = {} if type(events) ~= 'table' then return out end for _, e in ipairs(events) do if e.y == year and e.m == month and not out[e.d] then out[e.d] = e.calendar or true end end return out end -- Days in a month, and the weekday its 1st falls on. -- -- os.time/os.date rather than a leap-year rule: the C library already knows, -- and a hand-rolled rule is one more thing to get wrong in a century year. -- Normalised to Monday = 1 .. Sunday = 7, because khal's firstweekday = 0 -- means Monday while os.date's wday means Sunday = 1. function M.month_shape(year, month) local first = os.date('*t', os.time({ year = year, month = month, day = 1, hour = 12 })) -- Day 0 of the next month is the last day of this one, which os.time -- normalises for us across the December boundary. local last = os.date('*t', os.time({ year = year, month = month + 1, day = 0, hour = 12 })) return last.day, (first.wday + 5) % 7 + 1 end -- The breaktimer cache written by bin/breaktimer-sample.sh. -- -- Returns a table describing the daemon, or nil when the cache is absent or -- carries no 'generated' key (the sampler never ran). The display strings are -- derived here rather than in the widget so the phase-to-word and -- phase-to-colour decisions can be tested without Cairo: -- -- { running, state, phase, frozen, remain, -- countdown = '24:31' or '--', -- phase_label = 'working' | 'micro-pausa' | 'pausa lunga' | 'in pausa' -- | 'outside working hours' | 'fermo', -- next = 'pausa' | 'lavoro' | '--', -- role = 'ok' | 'heading' | 'highlight' | 'warning' | 'label' } -- -- `frozen` is the sampler's read of whether the clock is ticking: the phase is -- 'working' but the remain file has stopped being rewritten. Outside the work -- window that is normal and reads as such; a hung daemon looks the same, and -- should. -- -- The countdown leads to the next phase, but the daemon keeps the cycle count -- in memory and never writes it, so whether the NEXT break is a micro or a long -- one is not knowable here. 'pausa' is the honest answer; the phase word carries -- the rest. function M.breaktimer_parse(text) if type(text) ~= 'string' then return nil end local kv = M.kv_parse(text) if not kv.generated then return nil end local running = kv.running == 'yes' local state = kv.state or 'stopped' local phase = kv.phase or 'stopped' local remain = tonumber(kv.remain) or 0 if remain < 0 then remain = 0 end local out = { running = running, state = state, phase = phase, frozen = kv.frozen == 'yes', remain = remain, } -- Not running and stopped are the same thing on the card. The sampler's -- liveness check is what turns a dead PID into this reading: the state file -- can still say 'running' after a crash or a kill -9. if not running or state == 'stopped' then out.countdown = '--' out.phase_label = 'fermo' out.next = '--' out.role = 'label' return out end out.countdown = string.format('%d:%02d', math.floor(remain / 60), remain % 60) if state == 'paused' then out.phase_label = 'in pausa' out.role = 'warning' elseif out.frozen then out.phase_label = 'outside working hours' out.role = 'label' else local labels = { working = 'working', breaking = 'micro-pausa', longbreak = 'pausa lunga' } local roles = { working = 'ok', breaking = 'heading', longbreak = 'highlight' } out.phase_label = labels[phase] or phase out.role = roles[phase] or 'value' end out.next = (phase == 'working') and 'pausa' or 'lavoro' return out end return M