diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index a8fdc71..d5e4620 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -55,11 +55,6 @@ runtime-computed `resume_offset` into the boot cmdline, and the dev box runs exactly this layout (`/proc/cmdline` verified) but isn't Nomarchy. Steps in HARDWARE-QUEUE. Close #76 on PASS. -### 80. Battery health readout (`VISION § C`) -Report-only: `cycle_count` + `charge_full`/`charge_full_design` % in a -doctor battery-health line (and optionally the Power menu). sysfs-gated -so desktops/VMs skip it. Extend checks.doctor with a faked sysfs battery. - ### 81. First-boot welcome card (`VISION § B`) One dismissible "you're set" toast on the first session (SUPER+M menu, SUPER+T themes, SUPER+? keys, network pointer) — a notification, **not** @@ -145,9 +140,9 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.* 2026-07-10). Keep #59 commented install guidance; no `nomarchy.hardware.nvidia.*` until a hybrid maintainer + queue. -_(First-boot card, generation readability, battery health, pre-activate - flag, dock life, hall of fame → promoted to **NEXT #80–#85**, - Bernardo 2026-07-11.)_ +_(#80 battery health shipped 2026-07-11. Remaining promoted: first-boot + card, generation readability, pre-activate flag, dock life, hall of + fame → **NEXT #81–#85**.)_ ### v1.0 pointer diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 3fdafca..ca8c828 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,21 @@ Template: --- +## 2026-07-11 — #80 doctor battery health readout +- **Task:** NEXT #80 — battery health in `nomarchy-doctor` (`VISION § C`). +- **Did:** Doctor row: `cycle_count` + retained capacity + (`charge_full`/`design` or `energy_*`) as `% of design`; system + batteries only; soft warn <70% design; skip when attrs absent. + `NOMARCHY_POWER_SUPPLY_ROOT` for the VM fixture. Extended + `checks.doctor` (fake BAT0 + Device-scope MOUSE0 ignore). HARDWARE §10, + ROADMAP ✓, VISION ✓; closed #80. +- **Verified:** **V2** — `nix flake check --no-build`; `checks.doctor` + green; local smoke on real BAT0 (342 cycles, 81% via energy_*) + fixture. +- **Pending:** Power-menu surface left optional/out of scope (doctor is + the product surface for report-only health). +- **Next suggestion:** #81 first-boot welcome card (highest product ROI) + or #85 docs-only hall of fame. + ## 2026-07-11 — Triage: VISION-gap PROPOSED → NEXT #80–#85 (Bernardo) - **Task:** Bernardo promoted five of the six fresh VISION-gap pitches (all but the wallpapers split, which stays LATER). diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md index 8b74edc..a08feda 100644 --- a/docs/HARDWARE.md +++ b/docs/HARDWARE.md @@ -378,11 +378,10 @@ For contributors (and power users who will PR): ## 10. Doctor and hardware health (current vs target) **Today** (`nomarchy-doctor`): failed units, disk space, theme-state -validity/git, generation age, snapper timer. **No** Wi‑Fi, GPU, fwupd, -or fingerprint checks. - -**Target checks** (queued, all read-only, each failure prints one fix -command — same doctor contract): +validity/git, generation age, snapper; hardware section self-gates per +machine (NetworkManager, audio sink, GPU smoke, fprintd, fwupd, charge +limit, battery health, hibernate/zram). All read-only; each ✖ prints one +fix command. | Check | Pass condition | Suggested fix line | |-------|----------------|--------------------| @@ -391,7 +390,9 @@ command — same doctor contract): | GPU accel | `glxinfo`/`vainfo` smoke (if installed) | check `hardwareProfile` / drivers | | Fingerprint | if USB VID matched / fprintd unit | `fprintd-enroll` or enable option | | fwupd | daemon active; optional “updates pending” warn | `fwupdmgr get-updates` | -| Battery threshold | if laptop + limit set, sysfs writable | power docs | +| Battery threshold | if laptop + limit set, sysfs reports it | power docs / restart charge-limit unit | +| Battery health | cycle_count and/or charge\|energy_full÷design % when sysfs exports them | report-only (warn if <70% of design) | +| Hibernate / zram | disk swap + resume=; zram active | docs/MIGRATION.md → Enabling hibernation | ## 11. Option quick reference diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c744b70..a55c6ac 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -363,6 +363,12 @@ Design/decision records and a running log of shipped work (items marked `checks.doctor` VM test asserts the `hibernate` row renders. (Gotcha recorded: the script runs `set -euo pipefail`, so a no-match `grep` inside `$(…)` or a `cond && action` aborts it — use `|| true` and `if`.) + ✓ **Doctor battery health (#80, `VISION § C`):** report-only + `cycle_count` + retained capacity (`charge_full`/`charge_full_design` or + `energy_*`) as `% of design`, system batteries only (not Device-scope + peripherals). Self-gates when attrs missing; soft warn below 70% of + design. `NOMARCHY_POWER_SUPPLY_ROOT` for the `checks.doctor` fixture + (`test_power` has no cycle/design attrs). - ✓ **Hibernate double-unlock:** on resume from hibernate the LUKS passphrase already gates the machine, but locking hyprlock before sleep meant a second password. Fixed by *not locking* before an encrypted diff --git a/docs/VISION.md b/docs/VISION.md index abaeb67..fe0ad5f 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -82,7 +82,7 @@ Reference: [HARDWARE.md](HARDWARE.md) §4–§5, §10. |------|--------|-------| | **Dock life** | Lid closed + external only, wake, default audio sink on undock | Display profiles exist — polish edge cases | | **Hibernate/sleep confidence** | Doctor: resume device, swap size, clean suspend journal | Trust > new power UI | -| **Battery health readout** | Cycles / charge limit where sysfs allows | Report-only | +| **Battery health readout** ✓ | Cycles + design capacity % in doctor where sysfs allows | ✓ shipped #80 (report-only; charge limit is a separate row) | | **Charge-limit instant apply** | Already PROPOSED `[blocked:hw]` | Keep privilege tradeoff explicit | --- diff --git a/flake.nix b/flake.nix index dd21d51..09e243f 100644 --- a/flake.nix +++ b/flake.nix @@ -428,8 +428,34 @@ # sheet must still exit 0 — i.e. the section runs without # erroring under `set -euo pipefail`. healthy = machine.succeed("nomarchy-doctor 2>&1") - for probe in ["NetworkManager", "fingerprint", "fwupd", "VA-API", "hibernate"]: + for probe in ["NetworkManager", "fingerprint", "fwupd", "VA-API", "hibernate", "battery health"]: assert probe in healthy, f"doctor hardware section missing a self-gated '{probe}' row:\n{healthy}" + # #80 battery health: faked sysfs (test_power has no cycle/design). + machine.succeed( + "mkdir -p /tmp/fake-ps/BAT0 && " + "echo Battery > /tmp/fake-ps/BAT0/type && " + "echo System > /tmp/fake-ps/BAT0/scope && " + "echo 150 > /tmp/fake-ps/BAT0/cycle_count && " + "echo 4000000 > /tmp/fake-ps/BAT0/charge_full && " + "echo 5000000 > /tmp/fake-ps/BAT0/charge_full_design" + ) + bat = machine.succeed( + "NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1" + ) + assert "BAT0 health" in bat, f"doctor missing battery health line:\n{bat}" + assert "150 cycles" in bat, f"doctor missing cycle_count:\n{bat}" + assert "80% of design capacity" in bat, f"doctor missing design %:\n{bat}" + # Peripheral Device-scope batteries must not produce a health line. + machine.succeed( + "mkdir -p /tmp/fake-ps/MOUSE0 && " + "echo Battery > /tmp/fake-ps/MOUSE0/type && " + "echo Device > /tmp/fake-ps/MOUSE0/scope && " + "echo 9 > /tmp/fake-ps/MOUSE0/cycle_count" + ) + bat2 = machine.succeed( + "NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1" + ) + assert "MOUSE0" not in bat2, f"doctor reported Device-scope battery:\n{bat2}" ''; }; diff --git a/pkgs/nomarchy-doctor/nomarchy-doctor.sh b/pkgs/nomarchy-doctor/nomarchy-doctor.sh index cbaf521..9b2c44e 100644 --- a/pkgs/nomarchy-doctor/nomarchy-doctor.sh +++ b/pkgs/nomarchy-doctor/nomarchy-doctor.sh @@ -263,6 +263,61 @@ if [ "$bat_seen" -eq 1 ] && [ "$bat_limited" -eq 0 ]; then skip "battery charge limit (none set — charges to 100%)" fi +# ── battery health (report-only, BACKLOG #80) ──────────────────────── +# cycle_count + retained capacity (charge_* µAh or energy_* µWh). Same +# system-battery filter as charge-limit/notify. Self-gates when no +# battery or the firmware omits the attrs (desktops, bare VMs). Override +# the sysfs root with NOMARCHY_POWER_SUPPLY_ROOT for the checks.doctor +# fixture (test_power has no cycle/design attrs). +ps_root="${NOMARCHY_POWER_SUPPLY_ROOT:-/sys/class/power_supply}" +bat_health_seen=0 +for bat in "$ps_root"/*/; do + [ -d "$bat" ] || continue + [ "$(cat "$bat/type" 2>/dev/null)" = Battery ] || continue + [ "$(cat "$bat/scope" 2>/dev/null || echo System)" = Device ] && continue + name=$(basename "$bat") + + cycles=$(cat "$bat/cycle_count" 2>/dev/null || true) + case "$cycles" in ''|*[!0-9]*) cycles= ;; esac + # Some firmwares export 0 forever — treat as unknown, not "brand new". + if [ -n "$cycles" ] && [ "$cycles" -eq 0 ]; then cycles=; fi + + full=""; design="" + if [ -r "$bat/charge_full" ] && [ -r "$bat/charge_full_design" ]; then + full=$(cat "$bat/charge_full" 2>/dev/null || true) + design=$(cat "$bat/charge_full_design" 2>/dev/null || true) + elif [ -r "$bat/energy_full" ] && [ -r "$bat/energy_full_design" ]; then + full=$(cat "$bat/energy_full" 2>/dev/null || true) + design=$(cat "$bat/energy_full_design" 2>/dev/null || true) + fi + case "$full" in ''|*[!0-9]*) full= ;; esac + case "$design" in ''|*[!0-9]*) design= ;; esac + pct="" + if [ -n "$full" ] && [ -n "$design" ] && [ "$design" -gt 0 ]; then + pct=$(( full * 100 / design )) + fi + + [ -n "$cycles" ] || [ -n "$pct" ] || continue + bat_health_seen=1 + + detail="" + [ -n "$cycles" ] && detail="${cycles} cycles" + if [ -n "$pct" ]; then + [ -n "$detail" ] && detail="$detail, " + detail="${detail}${pct}% of design capacity" + fi + # Soft warn only — wear isn't a doctor "fix", just a heads-up. + if [ -n "$pct" ] && [ "$pct" -lt 70 ]; then + warn "$name health: $detail" \ + "battery wear is normal over years — replace when runtime suffers" + else + ok "$name health: $detail" + fi +done +if [ "$bat_health_seen" -eq 0 ]; then + skip "battery health (no system battery / no cycle or design capacity attrs)" +fi + # ── hibernate / sleep (BACKLOG #76) ────────────────────────────────── # Read-only: is there a working hibernate path? zram is RAM-only and can't # hold a resume image, so hibernation needs a disk swap (partition or file)