phase: 05_ups_hero_battery_glyph

**Phase 05 final pass — all criteria met; whole project 100 % green.**

Verified (live upsd + isolated simulator):
- Real `apc`/`cyberpower`: Charge/Load/Runtime are the card's three largest elements (38.4 px vs 15.2 px next), coloured only by API `severity` — live right now 100 % ok / 79.0 % **warn** / 5m **fail**; glyph fill = charge (100/100 viewBox units, `--ok` token), `<title>` "apc: Charge 100 percent, on line"; SIMULATED hidden; battery °C `n/a` uncoloured; every button disabled with its reason; `confirm_text: DANGER` on power-cutting commands; **0 non-GET requests** → `data/logs/ups-verify-live-{card,full}.png`.
- Simulator on `:8091` (own `/tmp` DB, killed after): caution dialog → Cancel, dangerous dialog → Run disabled until `DANGER` → Cancel, **0 POSTs**; one deliberate quick test *to the sim only* flipped the card to `OB` (glyph `stroke-dasharray="7 5"`, "on battery", health "discharging, on battery") then `OL CHRG` (bolt) then `OL` → `ups-verify-sim-{dialog-caution,dialog-danger,onbattery-button,onbattery-page,charging-button}.png`; glyph matrix `ups-glyph-*.png` (100/mid/low/unknown/empty/unrated, dark/light/200 %) present; `grep -c "function battery"` = 1.

Defects found in earlier work and fixed:
1. **`poller.py` could stop polling in silence** — 4 of 6 dev-server sources had frozen `attempts` (ages 12–38 min) while `/api/health` still said `ok: true`: the store-write half of `run_once` was outside the `try`, and a dead task's exception is never retrieved. Now `_commit()` moves `last_ok` only after the write lands, `_store_refused()` reports it (`ok:false`, real reason, one `store-error` event + one `recording` on recovery, payload kept), and `_loop()` catches anything that escapes and keeps polling — 4 new tests in `tests/test_poller.py`.
2. **`nut_sim.py` (modified by this phase) sat at 83 % coverage**; its whole command path was untested. Added tests (every advertised command implemented, unknown → `ValueError`, alarms → card + `assess()` fail, `NUT_SIMULATE` truthy rule) and fixed two model lies: `beeper.*` answered OK while changing nothing (now publishes `ups.beeper.status` enabled/muted/disabled) and a finished self-test never wrote `ups.test.result` (aborted writes none). → 100 %.
3. `nut_sim.from_env()` was dead code duplicating `app._truthy`; `app.py` now builds the simulator through it, so the enable gate lives once.

Tests / lint (exact): `bash .agents/validate.sh` → **EXIT=0** — `uv run pytest --cov --cov-fail-under=94` **543 passed, 8 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean; repo hygiene clean. `uv run pytest -m live --live` **with `.env.dev` sourced → 8 passed** (one earlier run: 2 transient `9121/snapshot timed out` against the 4 s `HOST_TIMEOUT`; three later runs 8/8). `uv pip list --outdated` empty. Dev server restarted on the final code: `health ok`, 6/6 sources fresh.

Docs: AGENTS.md — invariant 4 extended ("nothing may stop polling in silence" + store-refusal semantics), `poller.py`/`nut_sim.py` responsibility lines, 533→543 and 96.9 %→97.9 %, "~97 %"→"~98 %". README.md — poller bullet gained the same guarantee, Testing gained "source `.env.dev` before `-m live --live`" (bare runs skip 7 of 8 live tests, which reads like a pass), Battery-test section gained the `deploy/nut-sudoers` install line and the read-before-write warning.

Notes/deviations: no live instant command was ever sent to real hardware (the four gates leave them disabled — AGENTS gap 1 unchanged); the on-battery proof was driven through the real button against `NUT_SIMULATE=1`, which is stronger than the NOTES hand-driven states. `NOTES-verify.md` under `todo/` left untouched per instructions. Next phase: **none** — 05 is the last in the PLAN.md roadmap.
This commit is contained in:
2026-10-17 10:27:23 -04:00
parent f44a617ef9
commit 34854d32af
44 changed files with 2374 additions and 105 deletions
@@ -0,0 +1,109 @@
# Verification notes — the hero row on real batteries, and the states only the sim can give
**Date:** 2026-10-17. Real pass: dev tunnels 9121/9122/9123 + upsd forward `3494 → driveripper:3051`
all OK, `dev-server.sh status` six sources `ok`, `/api/ups/snapshot` `simulated: false`.
Simulator pass: the production app (`create_app()` with the real poller, payload builder and
severity table) run in-process on `127.0.0.1:8091` with `NUT_SIMULATE=1` — states driven through
`SimUps.tick()/step()` and `NutMonitor.read()` exactly as the 5-second poll loop drives them.
All screenshots are headless Chromium (the accepted verification browser for phases 02–04), dark
theme, poll paused before each shot; every shot was opened and read, not just written.
## 1. Real fleet — `data/logs/ups-real-apc.png`, `ups-real-cyberpower.png`
| | `apc` (Back-UPS RS 1500MS) | `cyberpower` (GX1500U / PR1500LCDRT2U label) |
| --- | --- | --- |
| glyph | full green cell, solid shell, no bolt | same |
| Charge | **100 %** green, `24 V nominal · 27.3 V` | **100 %** green, `24 V nominal · 27.2 V` |
| Load | **48.0 %** green + bar (no `realpower` from this driver → no watt sub) | **14.0 %** green + bar, `127 W of 900 W` |
| Runtime left | **10m** green, `at the current load` | **56m** green, `at the current load` |
| Battery age check | **warn** — "5.3 years — past the 4-year replacement window" | **unknown** — "no battery.mfr.date / battery.date from this driver" (`battery.mfr.date: CPS` did not become a fake age) |
| Battery °C | `n/a`, uncoloured | `n/a`, uncoloured |
| health chip | `degraded` (from the age warn) | `degraded` (from "No test initiated") |
| SIMULATED badge | **absent** (`hidden`, and no banner) — real upsd read | **absent** |
The two driver disagreements the task calls the point are visibly different on the page: the APC
gets an amber age warning, the CyberPower gets a `?` "unknown" — the page is not rendering a
template. Zero POSTs on the real page.
## 2. States only `nut_sim.py` can produce — five shots, badge visible in every one
`NUT_SIMULATE=1`, `NUT_ALLOW_ACTIONS=0`; apc card driven to each state (cyberpower left at 100 %
on line as the contrast). The `SIMULATED` chip is visible on the card header in every shot, and
the full-page shot (`ups-sim-12-low-page.png`) shows the "SIMULATED DATA. NUT_SIMULATE=1" banner
plus the "Command buttons are disabled" info banner.
| Shot | State proven (API-confirmed before the shot) |
| --- | --- |
| `ups-sim-100-online.png` | 100 % / `ok` green, glyph solid shell **no bolt**, chips `fully charged, online, OL`, health healthy |
| `ups-sim-60-charging.png` | 59 % / `ok` green, glyph fill ~59 % **with bolt**, chips `charging, online, OL CHRG` |
| `ups-sim-35-onbattery.png` | 34 % / **`warn` amber**, glyph dashed shell (on-battery shape), chips `discharging, on battery, OB`, battery V 21.8 V |
| `ups-sim-12-low.png` (+ `ups-sim-12-low-page.png`) | 12 % / **`fail` red**, `OB LB` → chip `low battery[fail]`, glyph red dashed shell nearly empty, runtime `9m` amber, health `at risk`, events `apc OL → OL CHRG → OB → OB LB` |
| `ups-sim-nacharge.png` | `battery.charge`/`battery.runtime` not exported → Charge **`n/a`** and Runtime **`n/a`**, no severity class on either, glyph = grey outline, empty body, no colour; checks say "battery.charge is not exported" / "battery.runtime is not exported (the driver cannot estimate it)"; health chip `unknown` |
Runtime on the low-charge shots is the model's own estimate (`step()` drain under load), not a
typed-in number; the 99.9 %→`full` and <20 %→`LB` boundaries fired on their own as charge fell.
## 3. Defects found and fixed while doing this
1. **The simulator could not produce the "charging" state.** `SimUps.vars()` answered `ol`/`OL`
while the pack was recharging and `ol` even while `OB` near full, so `charger_status == "chg"`
— the only route to the glyph's bolt — was unreachable in dev, and flag and token
contradicted each other. Fixed (`sources/nut_sim.py`): on battery → `OB`(+`LB`)/`dchg`;
on line below float → `OL CHRG`/`chg` (exactly what a real APC prints); ≥99.9 % → `OL`/`full`.
Pinned by `test_a_recharging_simulated_ups_reports_charging_like_a_real_driver` and
`test_a_discharging_simulated_ups_is_never_online`. The `ups-sim-60-charging.png` above was
shot after the fix; that state is now reproducible for anyone (`drain on battery`, come back
on line).
2. **The chip row said "charging" twice** on drivers exporting both `battery.charger.status` and
the legacy flag (made visible by 1). `UpsReading.states()` now emits one chip per fact — the
2.8 token wins, the subsumed flag is dropped — pinned by
`test_states_never_say_the_same_word_twice` (incl. the `dchg`+`OB DISCHRG` pair and the
flag-only route keeping both facts).
Both were pre-existing (the old code duplicated `online` the same way for any token+flag pair);
neither changes a stored metric, a severity, or a gate.
## 4. Dead source — `data/logs/ups-stale-lastgood.png`
Stopped **only the 3494 forward** (the generated supervisor + its `ssh -N`, the scoped variant of
`dev-tunnels.sh stop`; remote `nut-server` untouched). After 75 s: red banner
`upsd: NutError: cannot reach upsd at 127.0.0.1:3494`, both cards keep their last-good heroes
(100 % / 44.0 % / 12m, 100 % / 14.0 % / 59m — nothing zeroed, nothing `n/a`), dots red, header
`stale 75s`. Restored with `./scripts/dev-tunnels.sh start`; status and `/api/ups/snapshot` back
to answering real upsd (`simulated: false`).
## 5. The command gate — exercised, cancelled, nothing sent
On the live page every command button is disabled server-side ("NUT_USER / NUT_PASSWORD must name
a user with `actions` in upsd.users" — AGENTS gap 1, unchanged), so the dialog was exercised on
the simulator run **with `NUT_ALLOW_ACTIONS=1`**, where the buttons are genuinely enabled — no
DOM hacks — and Cancel guarantees nothing is sent:
- **Quick battery test** (caution) → dialog `risk: caution`, "this UPS is simulated" in the
target line, no DANGER input for a caution command → **Cancel**, dialog closed
(`ups-dialog-caution.png`).
- **Deep battery test** (dangerous) → dialog with `dangerous` fail chip, Run **disabled**, types
`DANGER` → Run enables (`action dangerous`) → **Cancel**
(`ups-dialog-danger-typed.png`).
- Fetch spy recorded **0 POSTs** across the session, and the sim's `ups.test.result` was still
`PASSED (2026-06-14)` afterwards — the command never reached a "driver".
No live instant command was sent; the operator did not ask for one. (`ups.test.result` on both
real UPSes remains `No test initiated` — still a warn row each.)
## 6. Note on the event feed during the outage
The event log in the stale screenshot carries failure/recovery flapping from ~08:33 onward that
predates this task (another process was restarting the forward and probing it — see the
`expected 'BEGIN LIST VAR', got 'CMD apc beeper:enable'` rows at 08:24, which is a raw-socket
probe sharing the port, not the dashboard's client; our poller speaks one request per
connection). Each row is a genuine transition, and the invariant-4 rule (one line per repeated
failure) held during the clean 09:10 outage: exactly one fail line and one recovery line.
## After the fixes
`bash .agents/validate.sh` → **EXIT=0**: 533 passed + 8 skipped, coverage **96.98 %** (floor 94),
ruff + pyright clean, `node --check` clean. `uv run pytest -m live --live` → **8 passed** (both
real UPSes through the hero contract, agents, engines). Docs: `AGENTS.md` — sim status semantics,
one-chip-per-fact rule, test count 530→533; no README change needed (`NUT_SIMULATE` wording was
already right).
@@ -0,0 +1,16 @@
**Phase 05 final pass — all criteria met; whole project 100 % green.**
Verified (live upsd + isolated simulator):
- Real `apc`/`cyberpower`: Charge/Load/Runtime are the card's three largest elements (38.4 px vs 15.2 px next), coloured only by API `severity` — live right now 100 % ok / 79.0 % **warn** / 5m **fail**; glyph fill = charge (100/100 viewBox units, `--ok` token), `<title>` "apc: Charge 100 percent, on line"; SIMULATED hidden; battery °C `n/a` uncoloured; every button disabled with its reason; `confirm_text: DANGER` on power-cutting commands; **0 non-GET requests** → `data/logs/ups-verify-live-{card,full}.png`.
- Simulator on `:8091` (own `/tmp` DB, killed after): caution dialog → Cancel, dangerous dialog → Run disabled until `DANGER` → Cancel, **0 POSTs**; one deliberate quick test *to the sim only* flipped the card to `OB` (glyph `stroke-dasharray="7 5"`, "on battery", health "discharging, on battery") then `OL CHRG` (bolt) then `OL` → `ups-verify-sim-{dialog-caution,dialog-danger,onbattery-button,onbattery-page,charging-button}.png`; glyph matrix `ups-glyph-*.png` (100/mid/low/unknown/empty/unrated, dark/light/200 %) present; `grep -c "function battery"` = 1.
Defects found in earlier work and fixed:
1. **`poller.py` could stop polling in silence** — 4 of 6 dev-server sources had frozen `attempts` (ages 12–38 min) while `/api/health` still said `ok: true`: the store-write half of `run_once` was outside the `try`, and a dead task's exception is never retrieved. Now `_commit()` moves `last_ok` only after the write lands, `_store_refused()` reports it (`ok:false`, real reason, one `store-error` event + one `recording` on recovery, payload kept), and `_loop()` catches anything that escapes and keeps polling — 4 new tests in `tests/test_poller.py`.
2. **`nut_sim.py` (modified by this phase) sat at 83 % coverage**; its whole command path was untested. Added tests (every advertised command implemented, unknown → `ValueError`, alarms → card + `assess()` fail, `NUT_SIMULATE` truthy rule) and fixed two model lies: `beeper.*` answered OK while changing nothing (now publishes `ups.beeper.status` enabled/muted/disabled) and a finished self-test never wrote `ups.test.result` (aborted writes none). → 100 %.
3. `nut_sim.from_env()` was dead code duplicating `app._truthy`; `app.py` now builds the simulator through it, so the enable gate lives once.
Tests / lint (exact): `bash .agents/validate.sh` → **EXIT=0** — `uv run pytest --cov --cov-fail-under=94` **543 passed, 8 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean; repo hygiene clean. `uv run pytest -m live --live` **with `.env.dev` sourced → 8 passed** (one earlier run: 2 transient `9121/snapshot timed out` against the 4 s `HOST_TIMEOUT`; three later runs 8/8). `uv pip list --outdated` empty. Dev server restarted on the final code: `health ok`, 6/6 sources fresh.
Docs: AGENTS.md — invariant 4 extended ("nothing may stop polling in silence" + store-refusal semantics), `poller.py`/`nut_sim.py` responsibility lines, 533→543 and 96.9 %→97.9 %, "~97 %"→"~98 %". README.md — poller bullet gained the same guarantee, Testing gained "source `.env.dev` before `-m live --live`" (bare runs skip 7 of 8 live tests, which reads like a pass), Battery-test section gained the `deploy/nut-sudoers` install line and the read-before-write warning.
Notes/deviations: no live instant command was ever sent to real hardware (the four gates leave them disabled — AGENTS gap 1 unchanged); the on-battery proof was driven through the real button against `NUT_SIMULATE=1`, which is stronger than the NOTES hand-driven states. `NOTES-verify.md` under `todo/` left untouched per instructions. Next phase: **none** — 05 is the last in the PLAN.md roadmap.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 26%]
........................................................................ [ 39%]
..................................................ssssssss.............. [ 52%]
........................................................................ [ 65%]
........................................................................ [ 78%]
........................................................................ [ 91%]
............................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 107 6 94% 50-51, 56, 58, 60, 127
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 373 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 476, 488, 523, 556, 560, 617-618, 623, 635
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2151 45 98%
Required test coverage of 94% reached. Total coverage: 97.91%
543 passed, 8 skipped, 1 warning in 47.58s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,29 @@
All checks green. Final verification summary:
```
uv run pytest → 518 passed, 8 skipped (live)
uv run pytest --cov --cov-fail-under=94 → 96.97% (sources/nut.py 95%)
uv run ruff check src tests agent → All checks passed
uv run pyright → 0 errors
node --check web/static/*.js → clean
uv run pytest -m live --live → 8 passed (real apc + cyberpower, engines, fleet)
bash .agents/validate.sh → EXIT=0 · uv pip list --outdated → empty
```
**Implemented**
- `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars`: verbatim `LIST UPS`/`LIST VAR`/`LIST CMD` replies from the real upsd (device serials deterministically scrubbed), recorded by the extended `scripts/capture-fixtures.sh` (new UPS section + serial scrubber) — no hand-typed variable table.
- `tests/ups_contract.py`: `HERO_FIELDS` / `UPS_SHAPES` / `parse_capture()` (uses the client's own wire splitter) / `assert_ups_hero_contract()` — the `/ups` twin of `card_contract.py`, shared with the live suite.
- `tests/test_ups_contract.py` (33 tests): every hero/glyph path on both real drivers; `battery_temperature` `None` (never 0) + `unknown` check; charge 100/45/15 → ok/warn/fail; runtime 3600/400/200 → ok/warn/fail and absent → `None`; served-JS proof `runtimeText(null)` returns the app's `naText` (`n/a`, not `0s`) and runs before any formatter; glyph state table (`ol`/`full`/`chg`/`dchg`), "full + on line never says charging", both on-battery routes, and a guard that `page-ups.js` never re-parses NUT flags.
- `tests/test_live_fleet.py::test_ups_hero_fields_are_present_on_the_real_batteries` — same table against live upsd, read-only.
- Live check after `dev-server restart`: both `apc` and `cyberpower` serve `severity` for charge/load/runtime, `battery_temperature: null`, `charger_status: null`, `simulated: false`.
**Defects found and fixed** (`sources/nut.py`)
- `battery_runtime` borrowed `battery.runtime.low` (a shutdown *threshold*) as the estimate — the hero would print a confident "5m left". It is now `battery.runtime` only; the threshold stays readable at `battery_runtime_low`.
- `charger_status` is normalised to the lowercase `CHARGER_STATUS` keys (as `states()`/`on_battery` already compare), so a driver answering `FULL` can't cost the glyph its state; `nut.py`'s protocol docstring now shows the verbatim 2.8.5 framing (`VAR apc ups.status "OL"` — quoting is optional).
- `page-ups.js`: `runtimeText()` returns `Dash.naText` instead of a duplicated literal (verified behaviourally with a throwaway node vm run: `n/a` / `10m` / `1h 32m`; no JS test added, per the locked anchor).
**Docs**: AGENTS.md (nut.py responsibilities incl. the runtime/charger rules, real-hardware quirks incl. "neither driver publishes `battery.charger.status`", UPS capture fixtures + test-count/command lines, testing-policy bullet for `ups_contract.py`) and README.md (fixture capture commands + UPS contract paragraph) updated.
**Notable decisions**: fixtures over hand-written vars (rule 7, and the drivers genuinely disagree — `battery.mfr.date: CPS`, `battery.date 2001/09/25` with slashes, only CyberPower exports `output.voltage`); no screenshots — task 01 changes no visuals, browser proof is task 04's mandate.
**Next pending task**: `05_ups_hero_battery_glyph/02_battery_glyph.md` (`DashCharts.battery()`).
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 41%]
......................................ssssssss.......................... [ 54%]
........................................................................ [ 68%]
........................................................................ [ 82%]
........................................................................ [ 95%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2110 64 97%
Required test coverage of 94% reached. Total coverage: 96.97%
518 passed, 8 skipped, 1 warning in 44.57s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,18 @@
**Implemented** (task `05_ups_hero_battery_glyph/02_battery_glyph.md`)
- `src/dashboards/web/static/charts.js` — `DashCharts.battery(container, {charge, severity, state, label, decorative})`: horizontal cell (rounded shell + nub) in one `<svg>`; `<rect>` fill = `charge/100` of the interior, clipped to the plate so 0 % leaves an empty shell; clamped 0–100 and never padded (verified: 2 % → 2.00 of 100 units, −5/1000/null → nothing); colour only from `cssVar('--ok'|'--warn'|'--fail')`, unrated measured charge = faint grey at half strength (shape, not verdict), unknown = no fill.
- State shapes readable without colour: solid shell `online`, bolt `charging`, dashed outline `onbattery`, dash pattern *through* the body `unknown`; an unknown state word degrades to `unknown`, never "on line".
- Accessible by default: `role="img"` + `<title>` + `aria-label` ("apc: Charge 100 percent, on line"), label escaped; documented opt-out `decorative: true` → `aria-hidden` (chosen: labelled, since the glyph says what the markup doesn't).
- Scales by container (`viewBox` + `preserveAspectRatio`, measured 210×91.9 = the 128:56 ratio); no animation/transition at all; exported in `window.DashCharts`.
- `dash.css` — only the `.battery` box (`width:100%; max-width:210px`, `svg{width:100%;height:auto}`).
- 6 new static-asset tests in `tests/test_api.py` (export, fill-honesty/clipping, token-only colour, state shapes, a11y, scaling/no-motion).
**Tests / lint / coverage**
- `uv run pytest` → **524 passed, 8 skipped**; `uv run pytest --cov --cov-fail-under=94` → **96.97 %**; `uv run ruff check src tests agent` → passed; `uv run pyright` → 0 errors; `node --check src/dashboards/web/static/*.js` → clean; `uv run pytest -m live --live` → **8 passed**; `bash .agents/validate.sh` → **EXIT=0**.
- Phase criterion: `curl -s localhost:8090/static/charts.js | grep -c "function battery"` → **1**.
- Browser matrix (live `/ups`, real upsd + synthetic inputs) in `data/logs/`: `ups-glyph-{100-ok-online,60-ok-charging,35-warn-onbattery,12-fail-onbattery,02-fail-onbattery,00-empty-onbattery,unknown,87-unrated-online}.png` + `ups-glyph-matrix-{dark,light}.png` + `ups-glyph-matrix-zoom200-light.png`; every state distinguishable in monochrome, both themes and 200 % zoom checked; all four pages still render live data after the shared-file change.
**Docs**: AGENTS.md (new UI-conventions paragraph for the glyph: fill-is-charge, state shapes, a11y choice, no animation, page derives `state`) and README.md (`web/` bullet) updated.
**Decisions**: unrated-but-measured charge draws a grey fill rather than an empty shell ("0 %" ≠ "not rated"); no fill transition added, so `prefers-reduced-motion` holds by construction; no notes file written in `todo/` (harness owns those). Dev server restarted, `/api/health` ok.
**Next pending task**: `05_ups_hero_battery_glyph/03_ups_hero_row.md`.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 40%]
............................................ssssssss.................... [ 54%]
........................................................................ [ 67%]
........................................................................ [ 81%]
........................................................................ [ 94%]
............................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2110 64 97%
Required test coverage of 94% reached. Total coverage: 96.97%
524 passed, 8 skipped, 1 warning in 46.62s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,16 @@
All green. Final report:
**Implemented** (`05_ups_hero_battery_glyph/03_ups_hero_row.md`)
- `page-ups.js`: `upsCard()` now opens with `.ups-hero` = battery glyph (`data-k="glyph"`) + three `.hero` slots — **Charge %**, **Load %**, **Runtime left** — with `.sub` context (`24 V nominal · 27.3 V`, `128 W of 900 W` only when both `realpower` figures exist, `at the current load`); line in / output / battery V / battery A / battery °C demoted to a visible-but-muted `.ups-electrical` row (not deleted). States chips, `SIMULATED` badge, health checks, command groups, NUT dump and the whole `#confirm-dialog`/`DANGER` flow untouched.
- `renderCard()`: three `Dash.hero()` calls coloured only from `severity.charge_pct|load_pct|battery_runtime`; **`severityFor()` deleted from this file** (no page script calls it anywhere now); runtime prose still only from `runtimeText()`, unmeasured → `null` so `hero()` prints faint, uncoloured `n/a`.
- `glyphState()` (on_battery → `chg` → `dchg`/`rec` → online tokens → an `ok`-rated `states[]` chip → `unknown`) + `drawGlyph()`, which repaints only when charge/verdict/state change and passes `decorative: true`. `dash.css`: `.ups-hero` (glyph column 112–156px, 140px hero track floor, stacks under 620px) and `.ups-electrical`.
- Tests: 6 new in `tests/test_api.py` (hero shape/order + `data-sev` hooks, payload-only colour, no bar on charge, secondary row demoted/muted/`n/a` faint, glyph-state mapping, repaint-only-on-change, row layout/stack) and `test_severity_for_is_kept_as_the_documented_fallback` rewritten to fail if any page re-invents a threshold.
- Browser (live apc + cyberpower, both themes, both real and synthetic states): `data/logs/ups-hero-row-{live,states}-{dark,light}.png`, `ups-hero-row-narrow.png`, `ups-hero-row-danger-dialog.png`. Measured: no clipped/overlapping numbers, glyph node identical across two polls, zero card/hero layout shift, `Battery °C` = faint `n/a` on both UPSes; dialog verified Cancel (caution) and the `DANGER` gate (Run disabled until typed) with a fetch spy recording **0 POSTs**.
**Verification**: `bash .agents/validate.sh` EXIT=0 → `530 passed, 8 skipped`, coverage `96.97%` (floor 94), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean; `uv run pytest -m live --live` → `8 passed`; `uv pip list --outdated` empty.
**Docs**: AGENTS.md — `severityFor` now "no page script calls it any more", new `/ups` hero-row convention (no bar on charge, sub rules, demoted electrical row, glyph-state mapping incl. "`full` is on line, not charging", repaint key, `decorative: true`), test count 518→530. README.md — `/ups` card described in the `web/` bullet.
**Decisions/deviations**: task text said `charger_status in ('dchg',) → 'charging'` — that's discharging, so `chg` charges and `dchg`/`rec` draw the on-battery shell; because **neither real driver publishes `battery.charger.status`**, the online case falls back to a state chip the API rated `ok` (`nut.states()` reserves `ok` for on-line/fully-charged) rather than re-parsing `ups.status` in JS (banned by `tests/test_ups_contract.py`). Documented remainder: a ≥10 h runtime (`10h 05m`, 161px) can reach past its 140px track into the row's right padding — it's the last slot, so it never lands on another number, and shortening the pinned prose was rejected. Dialog wiring was exercised with the buttons' `disabled` attribute removed client-side: the live API gates every command off (AGENTS gap 1 — no `actions` user in `upsd.users`), so no live config was touched and no command sent. Pre-existing, untouched: the shared masthead overflows below ~620px on every page (verified identically on `/system`). Mid-verification the dev server's UPS source alternated twice between real upsd and `NUT_SIMULATE=1` (another process); the final screenshots were re-taken after confirming `simulated=false`, `24 V nominal`, `battery_temperature=null`.
**Next pending task**: `05_ups_hero_battery_glyph/04_verify_real_batteries.md`.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 26%]
........................................................................ [ 40%]
..................................................ssssssss.............. [ 53%]
........................................................................ [ 66%]
........................................................................ [ 80%]
........................................................................ [ 93%]
.................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2110 64 97%
Required test coverage of 94% reached. Total coverage: 96.97%
530 passed, 8 skipped, 1 warning in 44.89s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,17 @@
All green, environment restored. Final report:
**Implemented (`05_ups_hero_battery_glyph/04_verify_real_batteries.md`)**
- Verified both real UPSes (`data/logs/ups-real-apc.png`, `ups-real-cyberpower.png`, both read): apc 100 %/48.0 %/10m green, age 5.3 y **warn**, temp `n/a`; cyberpower 100 %/14.0 % (`127 W of 900 W`)/56m, age **unknown** (`mfr.date: CPS` never became an age); `SIMULATED` absent on real reads.
- Five simulator states screenshotted with `NUT_SIMULATE=1` + `NUT_ALLOW_ACTIONS=0`, `SIMULATED` badge visible in each: `ups-sim-100-online/60-charging(bolt)/35-onbattery(amber)/12-low(OB LB, red)/nacharge(n/a, uncoloured, no fake runtime)` + full-page `ups-sim-12-low-page.png` (banner + transition events).
- **Defects found & fixed:** (1) `nut_sim.py` never reported `chg`/`CHRG` (and claimed `ol` while `OB`), making the glyph's bolt state unreachable in dev — now on-line-below-float = `OL CHRG`/`chg`, on-battery = `OB`/`dchg`; (2) `UpsReading.states()` said the same word twice (charger token + subsumed flag) — now one chip per fact. 3 new pinning tests in `tests/test_nut.py`.
- Stale path: stopped only the 3494 forward → `ups-stale-lastgood.png` shows last-good heroes + `stale 75s` + red dot/banner, nothing zeroed; forward restored, all sources ok.
- DANGER gate: exercised with genuinely-enabled buttons on the sim run (`NUT_ALLOW_ACTIONS=1` — Cancel-only, no command sent): caution dialog + Cancel, dangerous dialog Run-disabled→type `DANGER`→enabled→Cancel; **0 POSTs**, sim `ups.test.result` untouched (`ups-dialog-caution.png`, `ups-dialog-danger-typed.png`).
- Wrote `.agents/phases/todo/05_ups_hero_battery_glyph/NOTES-verify.md` (each shot ↔ state, defect writeup, note on pre-existing port-3494 probe events).
**Tests/lint:** `bash .agents/validate.sh` → EXIT=0 (`533 passed, 8 skipped`; coverage 96.98 % ≥ 94; ruff clean; pyright 0 errors; `node --check` clean). `uv run pytest -m live --live` → 8 passed. No deps moved.
**Docs:** AGENTS.md — nut_sim status semantics (`chg` reachable, `OB` never `ol`), one-chip-per-fact in `states()`, test count 530→533. README: no change needed (`NUT_SIMULATE` wording already accurate).
**Deviations:** the dialog pass ran with `NUT_ALLOW_ACTIONS=1` (sim only) because with `0` the buttons are legitimately disabled and cannot open the dialog; five-state pass kept `0` as instructed. No live instant command sent.
**Next pending task:** none in phase 05 — task 04 was the phase's last; harness may now complete/commit the phase.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 26%]
........................................................................ [ 39%]
..................................................ssssssss.............. [ 53%]
........................................................................ [ 66%]
........................................................................ [ 79%]
........................................................................ [ 93%]
..................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 373 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 476, 488, 523, 556, 560, 617-618, 623, 635
src/dashboards/sources/nut_sim.py 124 21 83% 152, 178-196, 237
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2117 64 97%
Required test coverage of 94% reached. Total coverage: 96.98%
533 passed, 8 skipped, 1 warning in 45.33s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==