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