From 3943e08dabe684b409a3ac99c9371fbeb5df5cdb Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 13 Oct 2026 11:35:00 +0200 Subject: feat: system, gpu, disks and network move to the quickshell island The quickshell island's performance panel now shows CPU, RAM, temperatures, the GPU, disks and network, and warns from the status pill when something runs hot, fills up, or the public IP drifts from the pinned one. Two dashboards drawing the same numbers would drift apart, so the four widgets go, with the disks and public IP samplers. The island keeps writing ~/.cache/udt/pubip.txt in the same format. The freed space is left empty for the user to rearrange. Co-Authored-By: Claude Sonnet 5.5 --- lib/card.lua | 145 +--------------------- lib/data.lua | 397 +---------------------------------------------------------- 2 files changed, 5 insertions(+), 537 deletions(-) (limited to 'lib') 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 = , colour = }. `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 ` 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 = { = }, @@ -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: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 -- cgit v1.2.3