aboutsummaryrefslogtreecommitdiffstats
path: root/lib/data.lua
blob: 2047ef806626481b4a49cd0aff0a61ccf82ba01b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
-- 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