From 6d068a0cdfe4188155e44826b839936ee8532375 Mon Sep 17 00:00:00 2001 From: MGrin Date: Sat, 5 Sep 2026 00:01:12 +1200 Subject: [PATCH] feat: battery level and info in the System panel, laptops only (MX-672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .gitignore | 1 + app.tsx | 52 ++++++++++++++++++++- lib/battery.ts | 123 +++++++++++++++++++++++++++++++++++++++++++++++++ server.ts | 100 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 lib/battery.ts diff --git a/.gitignore b/.gitignore index 1eae0cf..6b646f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ node_modules/ +.npmcache/ diff --git a/app.tsx b/app.tsx index b6562de..b947f10 100644 --- a/app.tsx +++ b/app.tsx @@ -15,11 +15,15 @@ import { } from "./components/ui/select"; import { Button } from "./components/ui/button"; import type { rpcContract } from "./server"; +import type { BatteryState } from "./lib/battery"; type Sample = { ts: number; cpuPct: number; load1: number; load5: number; cpuCount: number; memTotalMb: number; memUsedMb: number; memUsedFrac: number; pressureLevel: number; swapUsedMb: number; diskTotalGb: number; diskUsedGb: number; + // Optional at every layer: undefined means the sampler could not tell, which + // is neither "no battery" ({ present: false }) nor any number. lib/battery.ts. + battery?: BatteryState; }; type Current = { sample: Sample | null; @@ -77,6 +81,47 @@ function Tile(props: { label: string; value: string; sub: string; frac: number; ); } +function batteryDetail(b: Extract): string { + const parts = [b.charging ? "charging" : b.acConnected ? "on AC" : "on battery"]; + if (b.minutesRemaining !== undefined) { + const h = Math.floor(b.minutesRemaining / 60); + parts.push(h > 0 ? `${h}h ${b.minutesRemaining % 60}m left` : `${b.minutesRemaining}m left`); + } + if (b.healthPct !== undefined) parts.push(`health ${b.healthPct}%`); + if (b.cycleCount !== undefined) parts.push(`${b.cycleCount} cycles`); + return parts.join(" · "); +} + +// The three states render UNALIKE. A machine with no battery is not rendered by +// this component at all (SystemDetails omits the tile); a machine whose battery +// state could not be read says so in words. Neither ever draws a bar or a +// number, because an absent reading shown as 0% is a laptop about to die. +function BatteryTile({ battery }: { battery: BatteryState | undefined }) { + const label =
Battery
; + if (battery === undefined) { + return ( +
+ {label} +
Unknown
+
This machine did not report a battery state.
+
+ ); + } + if (!battery.present) return null; + const onBattery = !battery.charging && !battery.acConnected; + const tone = battery.pct === undefined ? "ok" : onBattery && battery.pct <= 20 ? "hot" : onBattery && battery.pct <= 40 ? "warn" : "ok"; + return ( +
+ {label} +
+ {battery.pct === undefined ? "Charge unknown" : `${battery.pct}%`} +
+ {battery.pct === undefined ?
: } +
{batteryDetail(battery)}
+
+ ); +} + function Spark({ points, title, max }: { points: number[]; title: string; max: number }) { if (points.length < 2) return null; const w = 280, h = 48; @@ -438,9 +483,13 @@ function SystemPanel() { function SystemDetails({ current, samples }: { current: Current; samples: Sample[] }) { const s = current.sample!; const pressure = PRESSURE[s.pressureLevel] ?? String(s.pressureLevel); + // A machine that reported "no battery" gets no tile — a permanent empty + // battery card on a desktop is noise. UNKNOWN still gets one, so the two + // cases stay distinguishable on screen. + const showBattery = s.battery === undefined || s.battery.present; return ( <> -
+
= 2} /> + {showBattery ? : null}
x.cpuPct)} max={100} /> diff --git a/lib/battery.ts b/lib/battery.ts new file mode 100644 index 0000000..68b763b --- /dev/null +++ b/lib/battery.ts @@ -0,0 +1,123 @@ +// Battery state for the System plugin — modelled as THREE states, never two. +// +// PRESENT a battery was found (a laptop): render the reading. +// ABSENT the machine reported that it has no battery (a desktop): render +// nothing. NEVER 0%. +// UNKNOWN the sampler could not tell — `si.battery()` threw, the remote +// sampler emitted no battery line, an old persisted row predates +// the column. Say so. NEVER a number. +// +// The reason this is a separate module with no plugin imports: every function +// here is pure, so the ABSENT and UNKNOWN arms can be exercised from a fixture +// on a machine that has a battery. This machine is a laptop, so those two arms +// are not otherwise reachable, and they are the arms that matter — a laptop +// rendering wrongly is visible on screen, a desktop rendering as "0% — about to +// die" is not visible to anyone here at all. +// +// The trap this exists to avoid is `server.ts`'s remote-value reader, +// `const num = (key) => Number(values.get(key)) || 0`: a key the sampler never +// emitted coerces to 0, and `|| 0` collapses a genuine 0 into the same 0. A +// battery routed through it turns a desktop into a dying laptop. Nothing here +// uses it; absence stays `undefined` the whole way through. +import { z } from "zod"; + +export const batteryStateShape = z.discriminatedUnion("present", [ + z.object({ present: z.literal(false) }), + z.object({ + present: z.literal(true), + // Optional even in the PRESENT branch: "there is a battery but its charge + // did not parse" is a real reading, and is not the same as no battery. + pct: z.number().optional(), + charging: z.boolean(), + acConnected: z.boolean(), + minutesRemaining: z.number().optional(), + cycleCount: z.number().optional(), + healthPct: z.number().optional(), + }), +]); +export type BatteryState = z.infer; + +const finite = (v: unknown): number | undefined => + typeof v === "number" && Number.isFinite(v) ? v : undefined; + +/** A percentage, or undefined — out-of-range readings are not clamped into range. */ +export const asPct = (v: unknown): number | undefined => { + const n = finite(v); + return n === undefined || n < 0 || n > 100 ? undefined : Math.round(n); +}; + +// IOKit reports 65535 (0xFFFF) or -1 when it has no estimate, and +// systeminformation passes that straight through: this MacBook returns +// timeRemaining 65535 while plugged in (measured 2026-09-04). Rendered raw +// that is "45 days remaining". Anything outside a day is not an estimate. +const MAX_PLAUSIBLE_MINUTES = 24 * 60; +export const asMinutes = (v: unknown): number | undefined => { + const n = finite(v); + return n === undefined || n <= 0 || n >= MAX_PLAUSIBLE_MINUTES ? undefined : Math.round(n); +}; + +/** Map a `systeminformation` battery() result. `hasBattery` is the discriminator. */ +export function batteryFromSi(raw: unknown): BatteryState | undefined { + if (!raw || typeof raw !== "object") return undefined; + const b = raw as Record; + // Not a boolean means the reading itself is unusable — UNKNOWN, not "no + // battery". Guessing "desktop" here would silently hide a laptop's battery. + if (typeof b.hasBattery !== "boolean") return undefined; + if (!b.hasBattery) return { present: false }; + const charging = b.isCharging === true; + const designed = finite(b.designedCapacity); + const max = finite(b.maxCapacity); + return { + present: true, + pct: asPct(b.percent), + charging, + acConnected: b.acConnected === true, + // A "time remaining" while charging is an estimate of the wrong thing. + minutesRemaining: charging ? undefined : asMinutes(b.timeRemaining), + cycleCount: finite(b.cycleCount), + healthPct: + designed !== undefined && max !== undefined && designed > 0 + ? Math.round((max / designed) * 100) + : undefined, + }; +} + +/** + * Map the remote sampler's `key=value` lines. A remote host may legitimately be + * a desktop, so an absent `battery_present` key is UNKNOWN and `battery_present=0` + * is ABSENT — two different answers, neither of them a number. + */ +export function batteryFromRemote(values: Map): BatteryState | undefined { + const present = values.get("battery_present"); + if (present === "0") return { present: false }; + if (present !== "1") return undefined; // absent key, or anything unrecognised + const numeric = (key: string): number | undefined => { + const raw = values.get(key); + if (raw === undefined || raw.trim() === "") return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + }; + const charging = values.get("battery_charging") === "1"; + return { + present: true, + pct: asPct(numeric("battery_pct")), + charging, + acConnected: values.get("battery_ac") === "1", + minutesRemaining: charging ? undefined : asMinutes(numeric("battery_minutes")), + }; +} + +/** NULL in the column means UNKNOWN, which is what every pre-migration row is. */ +export function encodeBattery(state: BatteryState | undefined): string | null { + return state === undefined ? null : JSON.stringify(state); +} + +export function decodeBattery(raw: unknown): BatteryState | undefined { + if (typeof raw !== "string" || raw.trim() === "") return undefined; + try { + const parsed = batteryStateShape.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; // a corrupt cell is UNKNOWN, never a reading + } +} diff --git a/server.ts b/server.ts index 9067b44..8b1f168 100644 --- a/server.ts +++ b/server.ts @@ -23,6 +23,13 @@ import { promisify } from "node:util"; import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; import si from "systeminformation"; import { z } from "zod"; +import { + batteryFromRemote, + batteryFromSi, + batteryStateShape, + decodeBattery, + encodeBattery, +} from "./lib/battery"; const run = promisify(execFile); const ACTIVE_MS = 15_000; // a thread is running a turn, or the panel is open @@ -46,6 +53,10 @@ const sampleShape = z.object({ swapUsedMb: z.number(), diskTotalGb: z.number(), diskUsedGb: z.number(), + // Optional on purpose, at every layer: absent means the sampler could not + // tell, which is a different answer from "this machine has no battery" + // ({ present: false }) and from any number at all. See lib/battery.ts. + battery: batteryStateShape.optional(), }); type Sample = z.infer; @@ -133,6 +144,15 @@ async function topProcesses() { const REMOTE_SAMPLE_SCRIPT = String.raw` set -eu platform=$(uname -s) +# Battery: a remote host may legitimately BE a desktop, so these stay empty +# unless the host actually answered. An empty battery_present emits NO line at +# all, which the parser reads as UNKNOWN — distinct from battery_present=0, +# which is the host saying it has no battery. +battery_present= +battery_pct= +battery_minutes= +battery_charging=0 +battery_ac=0 if [ "$platform" = "Darwin" ]; then cpu_count=$(sysctl -n hw.ncpu 2>/dev/null || echo 1) cpu_pct=$(top -l 2 -n 0 -s 1 2>/dev/null | awk '/CPU usage/ { idle=$7 } END { gsub(/%/, "", idle); if (idle == "") idle=100; printf "%.1f", 100-idle }') @@ -158,6 +178,23 @@ if [ "$platform" = "Darwin" ]; then }') pressure=$(sysctl -n kern.memorystatus_vm_pressure_level 2>/dev/null || echo 1) swap_used_kb=$(sysctl -n vm.swapusage 2>/dev/null | awk '{ for (i=1;i<=NF;i++) if ($i=="used") { v=$(i+2); sub(/M$/, "", v); printf "%.0f", v*1024 } }') + # pmset prints an InternalBattery line only on machines that have one; a Mac + # mini/Studio prints the AC line and nothing else. pmset missing entirely + # leaves battery_present empty => UNKNOWN. + batt=$(pmset -g batt 2>/dev/null || true) + if [ -n "$batt" ]; then + case "$batt" in + *InternalBattery*) + battery_present=1 + battery_pct=$(printf '%s\n' "$batt" | sed -n 's/.*[^0-9]\([0-9][0-9]*\)%.*/\1/p' | head -n 1) + # "1:23 remaining" -> 83. "(no estimate)" and "0:00" yield nothing. + battery_minutes=$(printf '%s\n' "$batt" | awk '/remaining/ { for (i=1;i<=NF;i++) if ($i ~ /^[0-9]+:[0-9][0-9]$/) { split($i, t, ":"); m=t[1]*60+t[2]; if (m > 0) printf "%d", m; exit } }') + case "$batt" in *"; charging"*|*"finishing charge"*) battery_charging=1 ;; esac + ;; + *) battery_present=0 ;; + esac + case "$batt" in *"'AC Power'"*) battery_ac=1 ;; esac + fi else cpu_count=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1) cpu_pct=$(top -bn2 -d 0.2 2>/dev/null | awk '/^%Cpu/ { idle=$8 } END { if (idle == "") idle=100; printf "%.1f", 100-idle }') @@ -170,6 +207,24 @@ else swap_total_kb=$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) swap_free_kb=$(awk '/^SwapFree:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) swap_used_kb=$((swap_total_kb-swap_free_kb)) + # No /sys/class/power_supply at all (a container, an exotic kernel) leaves + # battery_present empty => UNKNOWN rather than a claim about the hardware. + if [ -d /sys/class/power_supply ]; then + bat=$(find /sys/class/power_supply -maxdepth 1 -name 'BAT*' 2>/dev/null | head -n 1) + if [ -n "$bat" ]; then + battery_present=1 + battery_pct=$(cat "$bat/capacity" 2>/dev/null || true) + case "$(cat "$bat/status" 2>/dev/null || true)" in Charging) battery_charging=1 ;; esac + else + battery_present=0 + fi + for supply in /sys/class/power_supply/*; do + [ -f "$supply/type" ] || continue + case "$(cat "$supply/type" 2>/dev/null || true)" in + Mains) case "$(cat "$supply/online" 2>/dev/null || true)" in 1) battery_ac=1 ;; esac ;; + esac + done + fi fi disk_path=/ if [ "$platform" = "Darwin" ] && [ -d /System/Volumes/Data ]; then @@ -189,6 +244,15 @@ echo "pressure=$pressure" echo "swap_used_kb=$swap_used_kb" echo "disk_total_kb=$disk_total_kb" echo "disk_used_kb=$disk_used_kb" +if [ -n "$battery_present" ]; then + echo "battery_present=$battery_present" + if [ "$battery_present" = 1 ]; then + if [ -n "$battery_pct" ]; then echo "battery_pct=$battery_pct"; fi + if [ -n "$battery_minutes" ]; then echo "battery_minutes=$battery_minutes"; fi + echo "battery_charging=$battery_charging" + echo "battery_ac=$battery_ac" + fi +fi printf 'uptime=%s\n' "$(uptime 2>/dev/null || true)" ps -axo pid=,pcpu=,rss=,comm= 2>/dev/null | sort -k2,2nr | head -n 8 | while read -r pid cpu rss command; do printf 'cpu_proc=%s|%s|%s|%s\n' "$pid" "$cpu" "$rss" "$command" @@ -265,6 +329,12 @@ export default async function plugin(bb: BbPluginApi) { cpu_pct REAL, pressure_level INTEGER, PRIMARY KEY (host_id, ts) )`, + // NULLable, and NULL means UNKNOWN — so every row written before this + // migration keeps parsing and reports "battery state unavailable" rather + // than a fabricated 0%. Stored as one JSON cell instead of six columns + // because nothing queries the parts, and one nullable cell has exactly the + // three states the reading has. + `ALTER TABLE samples_by_host ADD COLUMN battery TEXT`, ]); const config = await bb.sdk.system.config(); @@ -306,11 +376,14 @@ export default async function plugin(bb: BbPluginApi) { const memTotalMb = Math.round(memInfo.total / 1048576); async function takeLocalSample(): Promise { - const [load, mem, fs, pressure] = await Promise.all([ + const [load, mem, fs, pressure, battery] = await Promise.all([ si.currentLoad(), si.mem(), si.fsSize(), pressureLevel(), + // A throw here must not lose the whole sample, and must not be read as + // "no battery": it is UNKNOWN. + si.battery().then(batteryFromSi, () => undefined), ]); const dataVol = fs.find((f) => f.mount === "/System/Volumes/Data") ?? fs.find((f) => f.mount === "/") ?? fs[0]; @@ -329,6 +402,7 @@ export default async function plugin(bb: BbPluginApi) { swapUsedMb: Math.round((mem.swapused ?? 0) / 1048576), diskTotalGb: dataVol ? Math.round(dataVol.size / 1073741824) : 0, diskUsedGb: dataVol ? Math.round(dataVol.used / 1073741824) : 0, + battery, }; } @@ -336,11 +410,12 @@ export default async function plugin(bb: BbPluginApi) { db.prepare( `INSERT OR REPLACE INTO samples_by_host (host_id, ts, load1, load5, cpu_count, mem_total_mb, mem_used_mb, mem_pressure, - swap_used_mb, disk_total_gb, disk_used_gb, cpu_pct, pressure_level) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + swap_used_mb, disk_total_gb, disk_used_gb, cpu_pct, pressure_level, battery) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ).run( hostId, s.ts, s.load1, s.load5, s.cpuCount, s.memTotalMb, s.memUsedMb, s.memUsedFrac, s.swapUsedMb, s.diskTotalGb, s.diskUsedGb, s.cpuPct, s.pressureLevel, + encodeBattery(s.battery), ); db.prepare(`DELETE FROM samples_by_host WHERE ts < ?`).run(Date.now() - RETAIN_MS); }; @@ -365,6 +440,9 @@ export default async function plugin(bb: BbPluginApi) { swapUsedMb: Number(r.swap_used_mb), diskTotalGb: Number(r.disk_total_gb), diskUsedGb: Number(r.disk_used_gb), + // Same shape as cpu_pct above: a missing column is a stated absence, not + // Number(null) === 0. + battery: decodeBattery(r.battery), }; }; @@ -514,6 +592,9 @@ export default async function plugin(bb: BbPluginApi) { swapUsedMb: Math.round(num("swap_used_kb") / 1024), diskTotalGb: Math.round(num("disk_total_kb") / 1048576), diskUsedGb: Math.round(num("disk_used_kb") / 1048576), + // Deliberately NOT num(): a remote host may legitimately be a desktop, + // and num() would render that as 0%. + battery: batteryFromRemote(values), }; return { sample, topCpu, topMem, uptime: values.get("uptime") ?? "" }; } finally { @@ -737,6 +818,16 @@ export default async function plugin(bb: BbPluginApi) { return "█".repeat(filled) + "░".repeat(width - filled); }; const PRESSURE = { 1: "normal", 2: "warning", 4: "critical" } as Record; + const batteryLine = (b: Sample["battery"]): string[] => { + if (b === undefined) return ["BAT unknown — this machine did not report a battery state"]; + if (!b.present) return []; + const detail = [b.charging ? "charging" : b.acConnected ? "on AC" : "on battery"]; + if (b.minutesRemaining !== undefined) detail.push(`${b.minutesRemaining} min left`); + if (b.healthPct !== undefined) detail.push(`health ${b.healthPct}%`); + if (b.cycleCount !== undefined) detail.push(`${b.cycleCount} cycles`); + if (b.pct === undefined) return [`BAT charge unknown · ${detail.join(" · ")}`]; + return [`BAT ${bar(b.pct / 100)} ${b.pct}% · ${detail.join(" · ")}`]; + }; bb.cli.register({ name: "system", @@ -800,6 +891,9 @@ export default async function plugin(bb: BbPluginApi) { (s.swapUsedMb > 0 ? ` · swap ${(s.swapUsedMb / 1024).toFixed(1)} GB` : "") + ` · pressure ${PRESSURE[s.pressureLevel] ?? s.pressureLevel}`, `DISK ${bar(s.diskUsedGb / (s.diskTotalGb || 1))} ${s.diskUsedGb} / ${s.diskTotalGb} GB on the data volume`, + // No BAT line at all on a machine that reported no battery; the word + // "unknown" when it could not be read. Never a bar over an absence. + ...batteryLine(s.battery), up.trim(), ].join("\n"), };