Skip to content

feat: battery level and info in the System panel, laptops only (MX-672) - #12

Merged
MGrin merged 1 commit into
mainfrom
mx-672-battery
Sep 4, 2026
Merged

MGrin merged 1 commit into
mainfrom
mx-672-battery

Conversation

@MGrin

@MGrin MGrin commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Closes MX-672. mgrin: "can you add the battery level and info to the bb system plugin that we own? For laptops only? Would be good to see it in the plugin page".

What landed

A Battery tile in SystemDetails (the plugin page), a BAT line in bb system overview, and the battery carried through sampling, persistence and the remote-host round trip. Home tiles untouched — argument for that at the bottom.

"Laptops only" is si.battery().hasBattery, a field, not a heuristic. No model-name sniffing.

Three states, rendered unalike

state panel CLI meaning
PRESENT tile: charge, meter, on AC · health 79% · 150 cycles BAT ████… 100% · on AC · health 79% · 150 cycles there is a battery
ABSENT no tile no line the machine said it has no battery
UNKNOWN tile reading Unknown / "This machine did not report a battery state." BAT unknown — … the sampler could not tell

A desktop gets no tile because a permanent empty battery card is noise; UNKNOWN still gets one, so the two stay distinguishable on screen. Neither ever draws a bar or a number.

The trap named in the ticket is real and is avoided rather than worked around: server.ts's const num = (key) => Number(values.get(key)) || 0 turns a key the sampler never emitted into 0. Battery is deliberately not routed through it — batteryFromRemote() reads the map directly and returns undefined for an absent key. Absence stays undefined from sampler to screen.

All mapping lives in lib/battery.ts, which imports nothing from the plugin. That is what makes the ABSENT and UNKNOWN arms reachable from a fixture on a machine that has a battery.

Storage

One new column, battery TEXT, NULLable. NULL means UNKNOWN, so every pre-migration row keeps parsing and the sparkline keeps its history — same shape rowToSample() already uses for cpu_pct. The zod field is .optional() at every layer.

One JSON cell rather than six columns: nothing queries the parts, and one nullable cell has exactly the three states the reading has. Say the word if you'd rather have discrete columns.

Evidence

Every figure below re-measured on this worktree (env_u94tz5e6zf, branch off main@71a7cec), 2026-09-04/05, macOS 26.5.2, node v26.7.0.

Gate. npm run typecheckrc=0. Scoring control: injecting const x: number = "nope"; into lib/battery.tsrc=2, TS2322; removed, back to rc=0. The green is a green from a gate that had just shown it could go red.

si.battery() on this MacBook (27.7 ms, so it is free to add to the Promise.all):

hasBattery: true, percent: 100, isCharging: false, acConnected: true,
cycleCount: 150, designedCapacity: 75053, maxCapacity: 59638,
timeRemaining: 65535

Disagreement with the brief, in your favour: timeRemaining here is 65535 — IOKit's 0xFFFF "no estimate", passed straight through by systeminformation. Rendered raw that is "45 days remaining". asMinutes() drops anything ≤ 0 or ≥ 24 h, and any estimate at all while charging (it is an estimate of the wrong thing). The ticket lists timeRemaining among the useful fields without flagging it; on this machine it is a sentinel, not a duration.

Remote sampler, live on this laptop (script extracted from REMOTE_SAMPLE_SCRIPT and run through /bin/sh) — agrees with si.battery():

battery_present=1
battery_pct=100
battery_charging=0
battery_ac=1

Remote sampler, negative arms (pmset shimmed on PATH; no desktop exists here):

fixture emitted
desktop (pmset prints the AC line only) battery_present=0 — and nothing else
discharging, 42%; discharging; 1:23 remaining present=1 pct=42 minutes=83 charging=0 ac=0
charging, 42%; charging; 0:47 remaining present=1 pct=42 minutes=47 charging=1 ac=1
pmset exits 1 no battery lines at all → UNKNOWN

Mapping, 19 fixture cases through batteryFromSi / batteryFromRemote / decodeBattery. The ones that matter:

si: desktop                        {"present":false}
si: threw / no reading             undefined
si: no hasBattery field            undefined
si: real laptop (measured)         {"present":true,"pct":100,"charging":false,
                                    "acConnected":true,"cycleCount":150,"healthPct":79}
si: charging (est. suppressed)     {"present":true,"pct":42,"charging":true,"acConnected":true}
si: genuine 0%                     {"present":true,"pct":0,...,"minutesRemaining":3}
remote: no battery lines           undefined
remote: desktop                    {"present":false}
remote: garbage present value      undefined
remote: present, pct unparseable   {"present":true,"charging":false,"acConnected":true}
db: corrupt cell                   undefined
db: wrong shape                    undefined

si: genuine 0% and si: desktop are the pair the whole design exists for: {present:true, pct:0} and {present:false} — two different answers, and || 0 would have made them one.

Migration, real sqlite (better-sqlite3, v1 schema created, a row inserted, then ALTER TABLE … ADD COLUMN battery TEXT, using server.ts's exact column lists):

pre-migration row (v1)   cell=null  -> undefined       (UNKNOWN, not 0%)
sampler could not tell   cell=null  -> undefined
desktop                  cell={"present":false}          -> {"present":false}
laptop at a genuine 0%   cell={"present":true,"pct":0,…} -> {"present":true,"pct":0,…}

The honest limits

  • The panel was not rendered in a browser. Installing this branch would swap the System plugin mgrin is running; that is a merge-and-deploy step, not a worker's. What is verified is the data reaching the component and the branch each state takes — not pixels.
  • The Linux arm of the sampler is unexercised. No Linux host here. It reads /sys/class/power_supply, treats a missing directory as UNKNOWN and a directory with no BAT* as ABSENT, and is written to the same rule as the Darwin arm — but that is code review, not a measurement.
  • batteryLine() (CLI) and BatteryTile were not fixture-tested — both live inside plugin() / the app bundle, so neither is importable. Straight-line rendering over parsers that were tested.

Finding, not built: this repo has no test runner

The three checks above (sampler arms, mapping fixtures, sqlite migration round trip) were run as throwaway scripts and deleted, per the brief. They are exactly the checks that would catch a regression in the arm nobody can see — a desktop rendering as 0% is invisible on a machine that has a battery. If you ever want a runner here, those three are the first tests to commit. Not adding one in this PR.

Home tiles: leaving them alone, and why

The three home tiles are CPU / Memory / Disk — all three are pressure readings, high is bad, and a glance is meant to say "is this box in trouble". Battery is the opposite polarity (low is bad) and, on a machine that is plugged in ~all the time, is a constant 100% · on AC. It would be a fourth tile that never changes and never means anything, on the surface with the least room. Happy to add it if you want it — say so and it is three lines.

Also in this PR

.gitignore gains .npmcache/npm install --cache "$PWD/.npmcache" is the install that works in the agent sandbox, and it leaves an untracked cache directory in the checkout.

`si.battery()` already carries `hasBattery`, so "laptops only" is a field
rather than a heuristic — no model-name sniffing.

A battery reading has THREE states and they render unalike:

  PRESENT  the reading, with charge, charging/AC, time left, health, cycles
  ABSENT   the machine said it has no battery — no tile, no CLI line, never 0%
  UNKNOWN  the sampler could not tell — said in words, never a number

The trap this is written around is the remote-value reader
`const num = (key) => Number(values.get(key)) || 0`: a key the sampler never
emitted coerces to 0, so a desktop would render as a battery about to die,
indistinguishable from a laptop that genuinely is. Battery is deliberately not
routed through it; absence stays `undefined` from sampler to screen.

All the mapping lives in `lib/battery.ts` with no plugin imports, so the ABSENT
and UNKNOWN arms are reachable from a fixture on a machine that has a battery —
which is every machine here.

* new `battery TEXT` column, NULLable; NULL is UNKNOWN, so every pre-migration
  row keeps parsing and the sparkline keeps its history
* new zod field is optional at every layer
* remote sampler emits battery lines only when the host actually answered, so a
  remote desktop round-trips as ABSENT and an unreadable host as UNKNOWN
* `si.battery()` rejecting yields UNKNOWN and does not lose the whole sample
* `timeRemaining` 65535 (IOKit's "no estimate", what this MacBook reports on
  AC) is dropped rather than rendered as 45 days
@MGrin
MGrin merged commit be9806c into main Sep 4, 2026
2 checks passed
@MGrin
MGrin deleted the mx-672-battery branch September 4, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant