**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.
7.6 KiB
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
- The simulator could not produce the "charging" state.
SimUps.vars()answeredol/OLwhile the pack was recharging andoleven whileOBnear full, socharger_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 bytest_a_recharging_simulated_ups_reports_charging_like_a_real_driverandtest_a_discharging_simulated_ups_is_never_online. Theups-sim-60-charging.pngabove was shot after the fix; that state is now reproducible for anyone (drain on battery, come back on line). - The chip row said "charging" twice on drivers exporting both
battery.charger.statusand 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 bytest_states_never_say_the_same_word_twice(incl. thedchg+OB DISCHRGpair 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
dangerousfail chip, Run disabled, typesDANGER→ Run enables (action dangerous) → Cancel (ups-dialog-danger-typed.png). - Fetch spy recorded 0 POSTs across the session, and the sim's
ups.test.resultwas stillPASSED (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).