feat: battery level and info in the System panel, laptops only (MX-672) - #12
Merged
Merged
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), aBATline inbb 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
on AC · health 79% · 150 cyclesBAT ████… 100% · on AC · health 79% · 150 cyclesUnknown/ "This machine did not report a battery state."BAT unknown — …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'sconst num = (key) => Number(values.get(key)) || 0turns a key the sampler never emitted into0. Battery is deliberately not routed through it —batteryFromRemote()reads the map directly and returnsundefinedfor an absent key. Absence staysundefinedfrom 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 shaperowToSample()already uses forcpu_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 offmain@71a7cec), 2026-09-04/05, macOS 26.5.2, node v26.7.0.Gate.
npm run typecheck→rc=0. Scoring control: injectingconst x: number = "nope";intolib/battery.ts→rc=2,TS2322; removed, back torc=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 thePromise.all):Disagreement with the brief, in your favour:
timeRemaininghere is 65535 — IOKit's 0xFFFF "no estimate", passed straight through bysysteminformation. 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 liststimeRemainingamong 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_SCRIPTand run through/bin/sh) — agrees withsi.battery():Remote sampler, negative arms (
pmsetshimmed onPATH; no desktop exists here):pmsetprints the AC line only)battery_present=0— and nothing else42%; discharging; 1:23 remainingpresent=1 pct=42 minutes=83 charging=0 ac=042%; charging; 0:47 remainingpresent=1 pct=42 minutes=47 charging=1 ac=1pmsetexits 1Mapping, 19 fixture cases through
batteryFromSi/batteryFromRemote/decodeBattery. The ones that matter:si: genuine 0%andsi: desktopare the pair the whole design exists for:{present:true, pct:0}and{present:false}— two different answers, and|| 0would have made them one.Migration, real sqlite (
better-sqlite3, v1 schema created, a row inserted, thenALTER TABLE … ADD COLUMN battery TEXT, usingserver.ts's exact column lists):The honest limits
/sys/class/power_supply, treats a missing directory as UNKNOWN and a directory with noBAT*as ABSENT, and is written to the same rule as the Darwin arm — but that is code review, not a measurement.batteryLine()(CLI) andBatteryTilewere not fixture-tested — both live insideplugin()/ 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
.gitignoregains.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.