aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/card.lua145
-rw-r--r--lib/data.lua397
2 files changed, 5 insertions, 537 deletions
diff --git a/lib/card.lua b/lib/card.lua
index 7fd40f8..2749a6e 100644
--- a/lib/card.lua
+++ b/lib/card.lua
@@ -215,11 +215,7 @@ end
-- fontconfig alone: a missing glyph draws as an invisible blank and a wrong
-- codepoint as a plausible neighbour, so neither fails visibly.
M.ICON = {
- system = '\u{F0EE0}', -- cpu-64-bit
- gpu = '\u{F083E}', -- video-4k-box (no GPU glyph exists)
- disks = '\u{F02CA}', -- harddisk
cache = '\u{F0ABA}', -- folder-clock
- network = '\u{F0317}', -- lan
slackware = '\u{F318}', -- linux-slackware
breaktimer = '\u{F13AB}', -- timer
calendar = '\u{F0E17}', -- calendar-month
@@ -272,7 +268,7 @@ end
-- Colour for a value against two thresholds.
--
--- One function so all four system cards agree on the rule instead of each
+-- One function so the cards agree on the rule instead of each
-- re-deriving it, and so "what counts as busy" is stated in a single place.
-- `v`, `warn` and `crit` share whatever unit the caller is using: percent for
-- a filesystem, degrees for a sensor.
@@ -283,78 +279,6 @@ function M.threshold(v, warn, crit, colors)
return colors.ok
end
--- A horizontal bar: a dim full-width track with a filled portion over it.
---
--- frac is clamped rather than trusted: a filesystem at 100% and a load that
--- briefly computes above 1.0 must not draw past the track.
-function M.bar(cr, x, y, w, h, frac, colour, colors)
- frac = tonumber(frac) or 0
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
-
- M.rgba(cr, colors.rule, 0.5)
- M.rounded_path(cr, x, y, w, h, h / 2)
- cairo_fill(cr)
-
- if frac > 0 then
- M.rgba(cr, colour, 1)
- M.rounded_path(cr, x, y, math.max(w * frac, h), h, h / 2)
- cairo_fill(cr)
- end
-end
-
--- A vertical bar, rising from its baseline. The equaliser's element.
-function M.vbar(cr, x, base_y, w, max_h, frac, colour, colors)
- frac = tonumber(frac) or 0
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
-
- M.rgba(cr, colors.rule, 0.5)
- cairo_rectangle(cr, x, base_y - max_h, w, max_h)
- cairo_fill(cr)
-
- local h = max_h * frac
- if h > 0 then
- M.rgba(cr, colour, 1)
- cairo_rectangle(cr, x, base_y - h, w, h)
- cairo_fill(cr)
- end
-end
-
--- A ring gauge, after idea2.png: a dim full circle with an arc over it
--- covering `frac`, leaving the centre free for a glyph.
---
--- The arc starts at twelve o'clock and sweeps clockwise, which is what reads
--- as a gauge. Cairo's zero angle is at three o'clock and it sweeps clockwise
--- already, so the start is offset by -pi/2 rather than the direction being
--- reversed.
-function M.ring(cr, cx, cy, r, frac, colour, colors, width)
- frac = tonumber(frac) or 0
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
- width = width or math.max(3, r * 0.18)
-
- -- Start a fresh path. A preceding card.text() leaves a current point behind,
- -- and cairo_arc() joins to it with a straight line, so without this every
- -- ring after a label is drawn with a stray chord to the text baseline.
- cairo_new_path(cr)
- local prev_width = cairo_get_line_width(cr)
- cairo_set_line_width(cr, width)
- cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
-
- M.rgba(cr, colors.rule, 0.5)
- cairo_arc(cr, cx, cy, r, 0, math.pi * 2)
- cairo_stroke(cr)
-
- if frac > 0 then
- M.rgba(cr, colour, 1)
- cairo_arc(cr, cx, cy, r, -math.pi / 2, -math.pi / 2 + math.pi * 2 * frac)
- cairo_stroke(cr)
- end
- -- Leave the line state as it was found. A later stroke inheriting ROUND gets
- -- visibly rounded ends on the card's hairlines, and one inheriting this
- -- width gets a hairline several pixels thick.
- cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT)
- cairo_set_line_width(cr, prev_width)
-end
-
-- The largest base size S at which every row of pieces still fits max_w,
-- measured at a reference size and scaled.
--
@@ -383,19 +307,6 @@ function M.fit_unit(cr, max_w, groups, ref)
return S
end
--- Bytes to a short human string: 1181116006 -> '1.1G'.
--- The card formats its own numbers because the samplers pass raw bytes.
-function M.human(bytes)
- local n = tonumber(bytes)
- if not n then return '--' end
- local units = { 'B', 'K', 'M', 'G', 'T', 'P' }
- local i = 1
- while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end
- if i == 1 then return string.format('%d%s', math.floor(n), units[i]) end
- if n >= 100 then return string.format('%.0f%s', n, units[i]) end
- return string.format('%.1f%s', n, units[i])
-end
-
-- Shorten a string to `limit` characters, appending an ellipsis when it cuts.
--
-- By character, never by byte: Lua's string.sub counts bytes, so slicing a
@@ -417,58 +328,4 @@ function M.truncate(s, limit)
return s:sub(1, cut - 1) .. '\u{2026}'
end
--- A line chart: several series sharing one baseline and one vertical scale.
---
--- `series` is a list of { values = <array>, colour = <rgb> }. `max` is the
--- shared ceiling; passing one rather than computing per series is the whole
--- point, because two series scaled independently lie about their relative
--- size: a 200kB/s upload would draw the same height as a 40MB/s download.
---
--- Values are drawn oldest-left, one per array entry, so a caller sizing its
--- history to the pixel width gets one sample per column and no interpolation.
--- A series shorter than its buffer draws only what it has, which is what a
--- freshly started dashboard shows while the window fills.
-function M.plot(cr, x, y, w, h, series, max, colors)
- if not (w > 0 and h > 0) then return end
- max = tonumber(max) or 0
- if max <= 0 then max = 1 end -- an idle link is a flat line, not a division by zero
-
- local prev_width = cairo_get_line_width(cr)
- local prev_cap = cairo_get_line_cap(cr)
-
- -- The baseline, so an empty chart still reads as a chart rather than a gap.
- M.rgba(cr, colors.rule, 0.5)
- cairo_new_path(cr)
- cairo_set_line_width(cr, 1)
- cairo_move_to(cr, x, y + h)
- cairo_line_to(cr, x + w, y + h)
- cairo_stroke(cr)
-
- cairo_set_line_width(cr, math.max(1.5, h * 0.02))
- cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
-
- for _, s in ipairs(series) do
- local v = s.values
- local n = #v
- if n >= 2 then
- -- One sample per column when the caller sized its buffer to the width.
- local step = w / math.max(n - 1, 1)
- M.rgba(cr, s.colour, 1)
- cairo_new_path(cr)
- for i = 1, n do
- local frac = v[i] / max
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
- local px = x + (i - 1) * step
- local py = y + h - frac * h
- if i == 1 then cairo_move_to(cr, px, py) else cairo_line_to(cr, px, py) end
- end
- cairo_stroke(cr)
- end
- end
-
- -- Leave the line state as it was found, for the same reason card.ring does.
- cairo_set_line_width(cr, prev_width)
- cairo_set_line_cap(cr, prev_cap)
-end
-
return M
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