Files
dashboards/.agents/phases/todo/05_ups_hero_battery_glyph/NOTES-verify.md
T
ducoterra 34854d32af 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.
2026-10-17 10:27:23 -04:00

7.6 KiB
Raw Blame History

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).