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
+93 -5
View File
@@ -35,6 +35,76 @@ for entry in "${HOSTS[@]}"; do
fi
done
# --- UPSes (NUT upsd) ---------------------------------------------------------
# The two real UPSes disagree in ways a hand-written VAR table cannot: the APC's
# `battery.date` is the factory placeholder `2001/09/25` (its `battery.mfr.date` is the real
# one), the CyberPower answers `battery.mfr.date: CPS`, and neither exports
# `battery.temperature` or `battery.charger.status`. `tests/test_ups_contract.py` pins the
# UPS hero row against these replies, so they are recorded from the wire, not typed from
# memory (AGENTS.md rule 7). Skipped when NUT_HOST is unset, so a laptop with no upsd
# reachable still re-records the rest.
if [ -n "${NUT_HOST:-}" ]; then
if python3 - "$FIX" "$NUT_HOST" "${NUT_PORT:-3493}" <<'PY'
import pathlib
import socket
import sys
fix, host, port = pathlib.Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])
HEADER = ("# Verbatim upsd reply (LIST UPS / LIST VAR / LIST CMD), recorded by\n"
"# ./scripts/capture-fixtures.sh — one `VAR`/`CMD` line per protocol line.\n"
"# Volatile values (ups.load, battery.runtime, the voltages) move every poll,\n"
"# so a diff here on re-capture is expected; the contract asserts shape, units\n"
"# and which variables the drivers omit, never those numbers.")
class Upsd:
"""Raw socket on purpose: the fixture is the wire, not a parse of it."""
def __init__(self, host: str, port: int) -> None:
self.handle = socket.create_connection((host, port), timeout=8).makefile("rwb")
def line(self) -> str:
raw = self.handle.readline()
if not raw:
raise EOFError("upsd closed the connection")
return raw.decode(errors="replace").rstrip("\r\n")
def ask(self, command: str) -> list[str]:
self.handle.write((command + "\n").encode())
self.handle.flush()
first = self.line()
if not first.startswith("BEGIN LIST"):
raise RuntimeError(f"{command!r} -> {first!r}")
rows: list[str] = []
while True:
row = self.line()
if row.startswith("END LIST"):
return rows
rows.append(row)
upsd = Upsd(host, port)
for row in upsd.ask("LIST UPS"):
if not row.startswith("UPS"):
continue
name = row.split()[1].strip('"')
body = [HEADER, row,
*upsd.ask(f'LIST VAR "{name}"'),
*upsd.ask(f'LIST CMD "{name}"')]
path = fix / f"nut_{name}.vars"
path.write_text("\n".join(body) + "\n")
print(f" ✓ nut_{name}.vars ({len(body) - 1} protocol lines)")
PY
then
: # per-UPS lines above
else
echo " ✗ nut_*.vars — upsd at $NUT_HOST:${NUT_PORT:-3493} unreachable (scripts/dev-tunnels.sh start?)"
failed=1
fi
else
echo " (skipped UPS captures — NUT_HOST unset; see .env.dev.example)"
fi
if [ "${1:-}" = "--with-llm" ]; then
if [ ${#ENGINES[@]} -eq 0 ]; then
echo " ! no GUFO_BASE_URL / VLLM_BASE_URL set (see .env.dev) — nothing to capture"
@@ -60,10 +130,12 @@ if grep -rlE "Authorization|Bearer |://[^/]*:[^/@]*@" "$FIX" >/dev/null 2>&1; th
failed=1
fi
# Two things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>) and the
# FQDN a host thinks it has. They are replaced deterministically so re-capturing does not
# churn the fixture, and the tests keep the same shapes to assert against.
# Three things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>), the
# FQDN a host thinks it has, and a UPS's `ups.serial`/`device.serial` (which identifies the
# physical unit, and is echoed back in the card's variable dump). They are replaced
# deterministically so re-capturing does not churn the fixture, and the tests keep the same
# shapes to assert against.
python3 - "$FIX" <<'PY'
import pathlib
import re
@@ -73,11 +145,15 @@ uuid_re = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{
SYNTHETIC = "00000000-0000-4000-8000-" # already-scrubbed markers: re-running must not churn
fix = pathlib.Path(sys.argv[1])
changed = []
# upsd quotes a field only when it has to, so both `VAR "apc" "ups.serial" "x"` and
# `VAR apc ups.serial "x"` are real wire shapes; the value is always quoted.
serial_re = re.compile(r'(^VAR\s+\S+\s+"?(?:device|ups)\.serial"?\s+")([^"\n]*)(")', re.M)
for path in sorted(fix.iterdir()):
if path.suffix not in (".json", ".txt"):
if path.suffix not in (".json", ".txt", ".vars"):
continue
text = path.read_text()
seen: dict[str, str] = {}
serials: dict[str, str] = {}
def swap(match: re.Match[str]) -> str:
value = match.group(0)
@@ -91,6 +167,18 @@ for path in sorted(fix.iterdir()):
# "hostname": "gw.lab.example.com" -> "gw": the dashboard's host key is the target name
# from HOST_TARGETS, never whatever the box calls itself
text = re.sub(r'("hostname": "[^"]*?)\.[^"]*(")', r"\1\2", text)
def scrub_serial(match: re.Match[str]) -> str:
value = match.group(2)
body, pad = value.rstrip(), value[len(value.rstrip()):]
if body.startswith("SCRUBBEDSERIAL"):
return match.group(0)
if body not in serials:
serials[body] = f"SCRUBBEDSERIAL{len(serials) + 1}"
# trailing spaces are a real quirk of the apc driver — keep the padding verbatim
return match.group(1) + serials[body] + pad + match.group(3)
text = serial_re.sub(scrub_serial, text)
if text != path.read_text():
path.write_text(text)
changed.append(path.name)