aboutsummaryrefslogtreecommitdiffstats
path: root/lib/data.lua
blob: c66aa42a7bc2f80b19cca966cb28a0282cde9e38 (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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
-- 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 <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> },
-- 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] = <calendar name> }. 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

-- The Immich memories index written by bin/immich-fetch.sh.
--
-- Returns a list of { id, year, taken = { y, m, d, H, M }, place }, plus
-- .url, the server's address for a click-through, and .week, true when the
-- photos are the past week's fallback rather than today's, or nil when
-- the index is absent or carries no 'generated' key. nil means "the fetch never
-- ran"; an empty list means "no memories today", which the card names
-- differently. `place` is nil when the key lacks asset.read or the photo has no
-- location, so the card can print '--'.
function M.immich_parse(text)
  if type(text) ~= 'string' then return nil end
  local out, generated = {}, false
  for line in text:gmatch('[^\n]+') do
    local kind, rest = line:match('^(%S+)%s+(.*)$')
    if kind == 'generated' then
      generated = true
    elseif kind == 'url' then
      out.url = rest
    elseif kind == 'scope' then
      out.week = rest == 'week'
    elseif kind == 'photo' then
      -- The place is last and takes the rest of the line, so a pipe in a
      -- place name cannot shift a field.
      local id, year, taken, place = rest:match('^([^|]+)|(%d*)|([^|]*)|(.*)$')
      if id then
        local y, m, d, H, Mi = taken:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+)')
        out[#out + 1] = {
          id = id,
          year = tonumber(year),
          taken = y and { tonumber(y), tonumber(m), tonumber(d),
                          tonumber(H), tonumber(Mi) } or nil,
          place = place ~= '' and place or nil,
        }
      end
    end
  end
  if not generated then return nil end
  return out
end

return M