aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 12:08:52 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 12:08:52 +0200
commit3690d0ee9ceb5c0287cfe72eb4dcfd8f43324f0e (patch)
tree7f7575c456fb3d1b4d75158f45ed0e339ec7cf22
parent64e8f20ce3a27dfc6cc2f37ae449dcb5eb91e7a9 (diff)
downloadconky-theme-udt-3690d0ee9ceb5c0287cfe72eb4dcfd8f43324f0e.tar.gz
conky-theme-udt-3690d0ee9ceb5c0287cfe72eb4dcfd8f43324f0e.zip
feat: add per-core CPU times and named sensor reads
-rw-r--r--lib/data.lua53
-rw-r--r--test/fixtures/proc_stat_percore11
-rw-r--r--test/test_data.lua37
3 files changed, 101 insertions, 0 deletions
diff --git a/lib/data.lua b/lib/data.lua
index 28dce1e..9bd5433 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -99,4 +99,57 @@ function M.new_cpu_counter()
}
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
+
return M
diff --git a/test/fixtures/proc_stat_percore b/test/fixtures/proc_stat_percore
new file mode 100644
index 0000000..cbf62f2
--- /dev/null
+++ b/test/fixtures/proc_stat_percore
@@ -0,0 +1,11 @@
+cpu 293470 5201 186259 16513195 82679 0 2095 0 0 0
+cpu0 6441 24 3225 1056496 1932 0 760 0 0 0
+cpu1 23587 123 15762 1018523 8299 0 112 0 0 0
+cpu2 9859 24 6098 1049850 2386 0 81 0 0 0
+cpu3 11200 30 7000 1040000 2500 0 90 0 0 0
+intr 123456789 0 0 0
+ctxt 987654321
+btime 1789600000
+processes 54321
+procs_running 2
+procs_blocked 0
diff --git a/test/test_data.lua b/test/test_data.lua
index 33551d1..e5b589f 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -54,4 +54,41 @@ local c2 = data.new_cpu_counter()
c2:sample(1000, 900)
assert(c2:sample(1000, 900) == nil, 'zero delta must give nil, not a division by zero')
+-- === Per-core CPU =========================================================
+-- The aggregate line answers "how busy is the machine"; the equaliser needs
+-- one entry per core. Both come from the same file, so they are parsed by the
+-- same rules and differ only in which lines they read.
+local percore = read('test/fixtures/proc_stat_percore')
+local cores = data.per_cpu_times(percore)
+assert(#cores == 4, 'one entry per cpuN line, got ' .. tostring(#cores))
+
+-- cpu0: 6441+24+3225+1056496+1932+0+760 = 1068878 total, idle+iowait = 1058428
+assert(cores[1].total == 1068878, 'core 0 total, got ' .. tostring(cores[1].total))
+assert(cores[1].idle == 1058428, 'core 0 idle, got ' .. tostring(cores[1].idle))
+
+-- Ordering matters: bar N must be core N, so the list follows the file.
+assert(cores[2].total == 1066406, 'core 1 total, got ' .. tostring(cores[2].total))
+
+-- The aggregate 'cpu ' line must NOT be counted as a core: it would draw a
+-- seventeenth bar showing the average, which looks like a real core.
+for i, c in ipairs(cores) do
+ assert(c.total < 2000000, 'entry ' .. i .. ' looks like the aggregate line')
+end
+
+-- A counter per core is just another instance, which is why new_cpu_counter
+-- holds its own previous sample rather than using a module-level one.
+local c0 = data.new_cpu_counter()
+assert(c0:sample(cores[1].total, cores[1].idle) == nil, 'first sample is nil')
+local busy = c0:sample(cores[1].total + 100, cores[1].idle + 25)
+assert(busy == 75.0, 'second sample 75%, got ' .. tostring(busy))
+
+-- Malformed input yields an empty list, never an error: a raise here is a
+-- blank dashboard.
+assert(#data.per_cpu_times('') == 0, 'empty input gives an empty list')
+assert(#data.per_cpu_times('garbage\nlines\n') == 0, 'garbage gives an empty list')
+assert(#data.per_cpu_times(nil) == 0, 'nil gives an empty list')
+-- A truncated line (fewer than the 5 fields the maths needs) is skipped
+-- rather than producing a nonsense total.
+assert(#data.per_cpu_times('cpu0 1 2\n') == 0, 'a short line is skipped')
+
print('test_data: all assertions passed')