phase: 12_nut_self_test_verdict
Final state verified. Report: **Verified (phase 12 — all 3 tasks already complete; this was the verification pass)** - Re-ran the wire capture live: `ups.test.result` = `No test initiated` (apc) / `Done and passed` (cyberpower) — matches `LIVE_SELF_TEST_WORDS` + both fixtures; `/api/summary` has **no** self-test row, `/api/ups/snapshot` reads `cyberpower ok | Done and passed`, `apc unknown | …run a battery test`; both screenshots read back correct. - Fixed a defect found in prior work while checking the "unrelated live noise" note: `NutMonitor.collect()` reused one upsd socket after a *partially-read* reply, so a 4 s stall produced bogus `expected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"'` errors (same hazard on `INSTCMD`, where a late `OK` could be read as a different command's success). `NutClient` now marks a socket whose reply stopped early as `UNUSABLE`, naming the command that stalled; a complete `ERR …` line still leaves it usable (the per-UPS tolerance `collect()` depends on). +3 hermetic pins against the fake upsd; live re-check (09:33–09:44) now logs one line per stall, no cascade. **Tests / lint / coverage (all green)** - `bash .agents/validate.sh` → **exit 0**; `uv run pytest --cov --cov-fail-under=94` → **1172 passed, 11 skipped**, coverage **98.63 %** (floor 94); `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean. Modified modules: `sources/nut.py` 95 %, `collectors.py` 97 % (no new line uncovered). - `uv run pytest -m live --live` → **8 passed / 3 failed**, all three the pre-existing `driveripper` agent-transport failures AGENTS.md records (verified still true: unit `inactive`, no installed file, nothing on `:9120`). - `./scripts/dev-server.sh status`: `nut ok`, `llm:* ok`, `host/disks:deskwork|gateway ok`; the two `driveripper` jobs DOWN. **Completion criteria** - Live capture recorded + asserts `ok` — met (re-confirmed from the wire today). - `/ups` renders `ok`, overview silent, screenshots present — met. - pytest / cov / ruff / pyright / validate — met (above). - No other `health.checks[]` behaviour changed — met (diff confined to the `test_result` branch; all prior nut/ups-contract tests unmodified). **Documentation**: README verified consistent (self-test wording, config table cross-checked against `config.py`, all documented commands/flags exist) — no changes needed beyond phase tasks' edit. AGENTS.md fixes: stale test count (1140 → 1172), `nvme0n1` power-on hours 19 158 → 19 160, gap 4 store-error claim corrected (last stored `store-error` 2026-10-18 06:33, none in the previous 24 h), new `collectors.py`/`sources/nut.py` socket-hygiene note, new gap-1 bullet on the upsd stall, plus live evidence appended to `data/logs/NOTES-self-test.md`. **Deviations**: made no live *changes* (read-only probes only) — `driveripper`'s agent install needs root (`ops` has no installer/visudo grant) and a battery test stays the operator's call; the upsd 4 s stall itself is upstream-side. `uv pip list --outdated` still advisory (certifi/fastapi/starlette) — dependencies untouched this phase. **Next pending phase**: `13_llm_page_and_decode_rate`.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
Final state verified. Report:
|
||||
|
||||
**Verified (phase 12 — all 3 tasks already complete; this was the verification pass)**
|
||||
- Re-ran the wire capture live: `ups.test.result` = `No test initiated` (apc) / `Done and passed` (cyberpower) — matches `LIVE_SELF_TEST_WORDS` + both fixtures; `/api/summary` has **no** self-test row, `/api/ups/snapshot` reads `cyberpower ok | Done and passed`, `apc unknown | …run a battery test`; both screenshots read back correct.
|
||||
- Fixed a defect found in prior work while checking the "unrelated live noise" note: `NutMonitor.collect()` reused one upsd socket after a *partially-read* reply, so a 4 s stall produced bogus `expected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"'` errors (same hazard on `INSTCMD`, where a late `OK` could be read as a different command's success). `NutClient` now marks a socket whose reply stopped early as `UNUSABLE`, naming the command that stalled; a complete `ERR …` line still leaves it usable (the per-UPS tolerance `collect()` depends on). +3 hermetic pins against the fake upsd; live re-check (09:33–09:44) now logs one line per stall, no cascade.
|
||||
|
||||
**Tests / lint / coverage (all green)**
|
||||
- `bash .agents/validate.sh` → **exit 0**; `uv run pytest --cov --cov-fail-under=94` → **1172 passed, 11 skipped**, coverage **98.63 %** (floor 94); `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean. Modified modules: `sources/nut.py` 95 %, `collectors.py` 97 % (no new line uncovered).
|
||||
- `uv run pytest -m live --live` → **8 passed / 3 failed**, all three the pre-existing `driveripper` agent-transport failures AGENTS.md records (verified still true: unit `inactive`, no installed file, nothing on `:9120`).
|
||||
- `./scripts/dev-server.sh status`: `nut ok`, `llm:* ok`, `host/disks:deskwork|gateway ok`; the two `driveripper` jobs DOWN.
|
||||
|
||||
**Completion criteria**
|
||||
- Live capture recorded + asserts `ok` — met (re-confirmed from the wire today).
|
||||
- `/ups` renders `ok`, overview silent, screenshots present — met.
|
||||
- pytest / cov / ruff / pyright / validate — met (above).
|
||||
- No other `health.checks[]` behaviour changed — met (diff confined to the `test_result` branch; all prior nut/ups-contract tests unmodified).
|
||||
|
||||
**Documentation**: README verified consistent (self-test wording, config table cross-checked against `config.py`, all documented commands/flags exist) — no changes needed beyond phase tasks' edit. AGENTS.md fixes: stale test count (1140 → 1172), `nvme0n1` power-on hours 19 158 → 19 160, gap 4 store-error claim corrected (last stored `store-error` 2026-10-18 06:33, none in the previous 24 h), new `collectors.py`/`sources/nut.py` socket-hygiene note, new gap-1 bullet on the upsd stall, plus live evidence appended to `data/logs/NOTES-self-test.md`.
|
||||
|
||||
**Deviations**: made no live *changes* (read-only probes only) — `driveripper`'s agent install needs root (`ops` has no installer/visudo grant) and a battery test stays the operator's call; the upsd 4 s stall itself is upstream-side. `uv pip list --outdated` still advisory (certifi/fastapi/starlette) — dependencies untouched this phase.
|
||||
|
||||
**Next pending phase**: `13_llm_page_and_decode_rate`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 66%]
|
||||
...................sssssssssss.......................................... [ 73%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 97%]
|
||||
............................... [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% 32
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 348 0 100%
|
||||
src/dashboards/app.py 178 0 100%
|
||||
src/dashboards/auth.py 265 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 120 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/disks.py 130 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 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 188 2 99% 255-256
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2843 39 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.63%
|
||||
1172 passed, 11 skipped, 1 warning in 94.23s (0:01:34)
|
||||
== 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 ==
|
||||
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||
certifi 2026.7.22 2026.10.18 wheel
|
||||
fastapi 0.143.5 0.148.0 wheel
|
||||
starlette 1.8.0 1.9.0 wheel
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
**Implemented (task 12/01 — pin the self-test wording)**
|
||||
- Captured `ups.test.result` from the live upsd (`10.1.0.126:3051`, 2026-10-19, direct from the dev box): `apc` = `No test initiated`, **`cyberpower` = `Done and passed`** (a test has run since the phase-05 capture).
|
||||
- `tests/test_nut.py`: new `LIVE_SELF_TEST_WORDS` table + 3 tests — fixture/capture drift pin (green), and two bug pins asserting the correct verdicts (`ok` for a pass, `unknown` for never-initiated).
|
||||
- Re-captured `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars` with the script's own NUT+scrub blocks, since the live CyberPower word differed from the fixture.
|
||||
- Confirmed the symptom live against the running dev server: `/api/summary` shows `warn cyberpower | Last self-test: Done and passed` and `warn apc | Last self-test: No test initiated`; `/api/ups/snapshot` `health.checks[]` carries both `warn` entries. Evidence in `data/logs/NOTES-self-test.md`.
|
||||
|
||||
**Tests / lint / coverage**
|
||||
- `uv run pytest --cov --cov-fail-under=94` → **1141 passed, 11 skipped, 2 xfailed**, coverage 98.62% (floor 94%).
|
||||
- `uv run ruff check src tests agent` clean · `uv run pyright` 0 errors · `bash .agents/validate.sh` **exit 0**.
|
||||
- `uv run pytest tests/test_nut.py -k self_test` → `2 passed, 2 xfailed`; with `--runxfail` both fail as intended: `assert 'warn' == 'ok'` and `assert 'warn' == 'unknown'`.
|
||||
- `uv run pytest -m live --live` → only the 3 pre-existing `driveripper` agent-transport failures recorded in AGENTS.md; UPS/engine live tests pass.
|
||||
|
||||
**Docs**: AGENTS.md fleet fact corrected — `ups.test.result` is no longer "the factory state on both"; it names each box's captured word and the `LIVE_SELF_TEST_WORDS` pin. No README impact (no behaviour change yet).
|
||||
|
||||
**Decisions / deviations**
|
||||
- The two pinning tests use `xfail(strict=True)` instead of being left red: the pipeline gate requires a green suite per task, and strict xfail becomes a hard failure the moment `assess()` is fixed, so task 02 must delete the marker as part of the fix (`--runxfail` is the recorded "it fails today" evidence).
|
||||
- Re-captured only the NUT fixtures; the scrub pass also wanted to normalise `Local Time is:` in the 16 `smartctl_driveripper_*.txt` captures (they predate that rule) — reverted as unrelated churn.
|
||||
- Not touched (out of scope, deps unmoved): `uv pip list --outdated` currently reports certifi/fastapi/starlette updates — worth a dependency pass, not a NUT-verdict task.
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/12_nut_self_test_verdict/02_verdict_map_for_test_results.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 68%]
|
||||
...............sssssssssss.............................................. [ 74%]
|
||||
.......................................................xx............... [ 81%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 99%]
|
||||
.. [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% 32
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 348 0 100%
|
||||
src/dashboards/app.py 178 0 100%
|
||||
src/dashboards/auth.py 265 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 120 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/disks.py 130 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 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 188 2 99% 255-256
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2820 39 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.62%
|
||||
1141 passed, 11 skipped, 2 xfailed, 1 warning in 61.73s (0:01:01)
|
||||
== 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 ==
|
||||
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||
certifi 2026.7.22 2026.10.18 wheel
|
||||
fastapi 0.143.5 0.147.0 wheel
|
||||
starlette 1.8.0 1.9.0 wheel
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Done — all green.
|
||||
|
||||
**Implemented**
|
||||
- `src/dashboards/sources/nut.py`: new explicit outcome map `SELF_TEST_OUTCOMES` + `self_test_outcome()` + `NEVER_TESTED_DETAIL`; the `assess()` `Last self-test` branch now grades the *outcome* — pass words → `ok` (incl. the captured live `Done and passed`), fail words → `fail`, `No test initiated`/empty/no variable → `unknown` with the existing "run a battery test" sentence, unrecognised word → `warn` printing the driver's raw word (+ `test_date` suffix kept).
|
||||
- `src/dashboards/collectors.py`: the UPS self-test **event** severity now calls `nut.self_test_outcome()` instead of its own duplicate pass tuple (same bug: a passed test was logged `warn` while the card beside it read green).
|
||||
- `tests/test_nut.py`: dropped the two task-01 `xfail(strict)` markers (now assert `ok` / `unknown` + exact detail), added `SELF_TEST_WORD_MAP` parametrized table (pass/fail/unknown/warn tiers incl. both live words), case/whitespace variants, unset-vs-never-initiated same-sentence, "no live driver word lands in warn", map-verdict sanity, simulator-word test.
|
||||
- `tests/test_collectors.py`: added `Done and passed` → `info` event case.
|
||||
- Docs: AGENTS.md (`sources/nut.py`, `collectors.py` bullets), README.md (`/ups` health-check paragraph + fixture-quirks paragraph).
|
||||
|
||||
**Verification**
|
||||
- `uv run pytest` → **1165 passed, 11 skipped**; `bash .agents/validate.sh` → **EXIT=0**, coverage **98.62%** (gate 94%), `nut.py` 95 % with the new `self_test_outcome()`/map lines fully covered, `ruff` "All checks passed!", `pyright` "0 errors, 0 warnings".
|
||||
- Live **read-only** check (dev server restarted, signed-in curl against real upsd): `cyberpower → ok | Done and passed`, `apc → unknown | no self-test result reported — run a battery test`; `/api/summary` emits **no** `Last self-test` alert anymore (only apc's legitimate `Battery age: 5.3 years` warn). No live writes.
|
||||
|
||||
**Notes / deviations**
|
||||
- Fixed the duplicate word list in `collectors.py` rather than special-casing the simulator; `nut_sim.py` needed no change (its `PASSED`/`FAILED` land in the right tiers, now asserted).
|
||||
- A word outside the map (e.g. an in-progress phrasing) stays `warn` by design, per the task spec.
|
||||
- validate.sh prints an advisory that certifi/fastapi/starlette are outdated; dependencies were not touched by this task.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/12_nut_self_test_verdict/03_no_false_warning_overview_and_ups.md` (API-level alert-absence test + the two screenshots).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 67%]
|
||||
...............sssssssssss.............................................. [ 73%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 97%]
|
||||
........................ [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% 32
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 348 0 100%
|
||||
src/dashboards/app.py 178 0 100%
|
||||
src/dashboards/auth.py 265 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 120 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/disks.py 130 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 395 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 539, 551, 586, 617, 621, 678-679, 684, 696
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 188 2 99% 255-256
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2825 39 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.62%
|
||||
1165 passed, 11 skipped, 1 warning in 60.54s (0:01:00)
|
||||
== 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 ==
|
||||
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||
certifi 2026.7.22 2026.10.18 wheel
|
||||
fastapi 0.143.5 0.148.0 wheel
|
||||
starlette 1.8.0 1.9.0 wheel
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
**Implemented (task 12/03 — no false warning on the overview and `/ups`)**
|
||||
- `tests/test_api.py`: new `self_test_app` harness (swaps the `nut` job's collector so it keeps serving the *same* payload the test mutates — no poll race) plus 4 pins: passed word → **no** `/api/summary` alert row *and* an `ok`/verbatim-word `health.checks[]` entry on `/api/ups/snapshot`; never-tested (`unknown`) → also silent; `FAILED` → exactly one `fail` row per UPS, `href=/ups`, id `<ups>:health:Last self-test`, check named once.
|
||||
- 4th pin proves **no page/CSS change was needed**: `<li class="${check.status}">` + `ul.checks li.ok .mark { color: var(--ok) }` is the whole render path, and no page/CSS rule names one check.
|
||||
- Live verification (dev server pid 960540, real upsd, `nut ok` before *and* after): `/api/summary` alerts = driveripper unreachable, apc battery age, deskwork memory — **no self-test row**; `/api/ups/snapshot` reads `cyberpower ok | Done and passed`, `apc unknown | …run a battery test`.
|
||||
- Screenshots read and confirmed: `data/logs/self-test-passed-overview.png`, `data/logs/self-test-passed-ups.png`; evidence appended to `data/logs/NOTES-self-test.md`.
|
||||
|
||||
**Tests / lint / coverage**
|
||||
- `uv run pytest` → **1169 passed, 11 skipped** (exit 0). `bash .agents/validate.sh` → **exit 0**: coverage **98.62%** (floor 94%), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` clean.
|
||||
- `uv run pytest -m live --live` → 8 passed / 3 failed, all three the pre-existing `driveripper` agent-transport failures AGENTS.md already records; every UPS/engine live test passes.
|
||||
|
||||
**Docs**: AGENTS.md testing-policy bullet (verdicts pinned on the surface that shows them, both directions; the no-new-markup rule). No README change — task 02's `/ups` paragraph already stated the behaviour and this task changed none.
|
||||
|
||||
**Decisions / deviations**: no `page-ups.js`/`ups.html`/`dash.css` edit (task said don't restyle — pinned instead); no battery test run on live hardware (phase assumption), so the APC screenshot shows the `unknown` dim state; live upsd emitted unrelated transient `did not answer in 4s` events before the pass — poller handled them per invariant 4, noted in NOTES, not a phase-12 regression; left `uv pip list --outdated` advisory (certifi/fastapi/starlette) untouched since dependencies were not moved here.
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/13_llm_page_and_decode_rate/01_fleet_strip_spacing.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 67%]
|
||||
...................sssssssssss.......................................... [ 73%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 97%]
|
||||
............................ [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% 32
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 348 0 100%
|
||||
src/dashboards/app.py 178 0 100%
|
||||
src/dashboards/auth.py 265 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 120 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/disks.py 130 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 395 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 539, 551, 586, 617, 621, 678-679, 684, 696
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 188 2 99% 255-256
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2825 39 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.62%
|
||||
1169 passed, 11 skipped, 1 warning in 62.95s (0:01:02)
|
||||
== 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 ==
|
||||
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||
certifi 2026.7.22 2026.10.18 wheel
|
||||
fastapi 0.143.5 0.148.0 wheel
|
||||
starlette 1.8.0 1.9.0 wheel
|
||||
@@ -213,8 +213,17 @@ Responsibilities, one line each:
|
||||
Events are stored under the **dashboard** name (`system`/`disks`/`llm`/`ups`), not the sample
|
||||
source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever
|
||||
(found on `/system` during phase 03 browser verification).
|
||||
- `collectors.py` — the fetch half. `NutMonitor` owns per-UPS transition bookkeeping. Failure
|
||||
messages name the **redacted** URL (`strip_userinfo` / `Target.display_url`), never the one
|
||||
- `collectors.py` — the fetch half. `NutMonitor` owns per-UPS transition bookkeeping (its
|
||||
self-test event takes its severity from `nut.self_test_outcome()` and not its own word list,
|
||||
so a passed test is never an amber line in the feed while the card beside it reads green).
|
||||
Its per-UPS tolerance for a failed `LIST CMD`/`LIST RW` is for a *complete* error reply only:
|
||||
one battery whose command list is denied loses its buttons and the poll goes on, while a
|
||||
**stalled** reply fails the poll, because `NutClient` marks a socket it could not read to its
|
||||
`END` as unusable (a reply still arriving would otherwise be taken for the *next* command's
|
||||
answer — the real cause of the `expected 'BEGIN LIST VAR', got 'RW apc …'` lines this fleet's
|
||||
event table used to fill with, and the same hazard on `INSTCMD`, where a late `OK` read as a
|
||||
different command's success would be a gate that lies).
|
||||
Failure messages name the **redacted** URL (`strip_userinfo` / `Target.display_url`), never the one
|
||||
that was dialled, because they become health records and event rows. `fetch_snapshot()` is
|
||||
the one agent `/snapshot` read that `system` and `disks` share, so a dead host says the
|
||||
same sentence on both pages and only the `timeout` differs (the disks poll has to outlast
|
||||
@@ -247,7 +256,13 @@ Responsibilities, one line each:
|
||||
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
|
||||
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
||||
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
||||
a mystery score), `COMMANDS` catalog with risk tiers, `COMMAND_ALIASES` + `resolve_command()`
|
||||
a mystery score), `SELF_TEST_OUTCOMES` + `self_test_outcome()` (the `Last self-test` verdict
|
||||
follows the *outcome* of the driver's `*.test.result` word, not its phrasing: pass words →
|
||||
`ok` (including `Done and passed`, what the CyberPower here answers), fail words → `fail`,
|
||||
`No test initiated` / empty / no variable → `unknown` printing `NEVER_TESTED_DETAIL` so both
|
||||
"never tested" states say one sentence, and an unrecognised word → `warn`, because a driver
|
||||
inventing a verdict is drift worth noticing and the check prints its own word to be added),
|
||||
`COMMANDS` catalog with risk tiers, `COMMAND_ALIASES` + `resolve_command()`
|
||||
(drivers answer two names for the same action — `beeper.enable`/`beeper.on`,
|
||||
`beeper.disable`/`beeper.off` — so one entry is offered per action and any of its names hits
|
||||
that entry's verdict, never a gate the rendered name missed), `available_commands()` filtering,
|
||||
@@ -566,7 +581,11 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
CyberPower reports `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age),
|
||||
**neither driver reports a battery temperature or the NUT 2.8 `battery.charger.status`**
|
||||
(so the battery glyph's state has to come from `on_battery`, and `charger_status` must stay
|
||||
optional), and `ups.test.result: No test initiated` is the factory state on both.
|
||||
optional), and **`ups.test.result` is where the two boxes disagree**: the APC still answers
|
||||
the factory `No test initiated` while the CyberPower answers `Done and passed` (a test has
|
||||
run on it since the phase-05 capture; both words re-read from the wire on 2026-10-19 and
|
||||
pinned as `LIVE_SELF_TEST_WORDS` in `tests/test_nut.py`, which fails if the fixture and the
|
||||
capture ever drift).
|
||||
Both UPSes are recorded verbatim (`LIST UPS`/`LIST VAR`/`LIST CMD`, serials scrubbed) as
|
||||
`tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, and the wire quotes a
|
||||
field only when it must — `VAR apc ups.status "OL"`, not three quoted fields.
|
||||
@@ -588,7 +607,8 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
What changed by 2026-10-19 is that `deskwork`'s **installed** service runs as
|
||||
`dashboards-agent`, which is in the `disk` group (`getent group disk` → `disk:dashboards-agent`)
|
||||
on an agent installed after the SMART pass: `/disks` shows real numbers there —
|
||||
`smartctl_available: true`, `nvme0n1` healthy at 52 °C, 19 158 power-on hours, 11 % of rated
|
||||
`smartctl_available: true`, `nvme0n1` healthy at 52 °C, 19 160 power-on hours (an odometer,
|
||||
so that figure is a capture date, not a fact about the drive), 11 % of rated
|
||||
wear — so the wear bar and the alarm chips are drawn from live hardware, not only fixtures.
|
||||
`gateway` answers `smartctl_available: false` (no smartmontools in its VM: the *install it*
|
||||
half of the three-way flag), and `driveripper` answers nothing at all until its agent exists,
|
||||
@@ -636,7 +656,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
|
||||
./scripts/dev-server.sh logs
|
||||
|
||||
uv run pytest # hermetic: 1140 passed + 11 live-skipped, no network
|
||||
uv run pytest # hermetic: 1172 passed + 11 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (98.6%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
uv run ruff check src tests agent && uv run pyright
|
||||
@@ -679,6 +699,17 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
|
||||
reading the end of an attribute row instead of its annotated RAW_VALUE column.
|
||||
- Anything that needs a failure mode — upsd refusing a command, an engine 500ing, a host
|
||||
rebooting mid-poll, a counter reset — is a fake in `tests/conftest.py`.
|
||||
- **A verdict the operator sees is pinned on the surface that shows it, in both directions.**
|
||||
`tests/test_nut.py` grades the word; `tests/test_api.py`'s `self_test_app` harness holds the
|
||||
`nut` job's own payload (its collector is swapped, so a poll cannot overwrite what the test
|
||||
mutated) and rewrites the `Last self-test` check in the object `/api/ups/snapshot` serves:
|
||||
a pass (`Done and passed`) and a never-tested pack produce **no** `/api/summary` alert row,
|
||||
a fail produces exactly one `fail` row per UPS linking to `/ups`. The two directions ship
|
||||
together on purpose — an absence-only assertion is satisfied by deleting the check, which is
|
||||
not the fix the report asked for (phase 12). The same file pins that the `/ups` check list
|
||||
needs **no** markup for a new verdict: `<li class="${check.status}">` plus
|
||||
`ul.checks li.<verdict> .mark` in `dash.css` is the whole render path, and no page asset or
|
||||
stylesheet rule names one check (a second opinion about the driver's word is the bug).
|
||||
- **Page payloads that a rewrite must not break are pinned as a table.**
|
||||
`tests/card_contract.py` holds the dotted `/api/system/snapshot` paths the `/system` hero
|
||||
cards read (`CARD_METRICS`, `GPU_METRICS`, `HOST_SHAPES`); `tests/test_system_contract.py`
|
||||
@@ -1106,6 +1137,15 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
- **Pre-existing upsd log noise:** every upsd start logs `Unknown upsmon type =` (20
|
||||
occurrences over 12 starts, oldest before this work). Harmless — `upsmon` logs into both
|
||||
UPSes right after it — but unexplained.
|
||||
- **One connection every few minutes answers nothing for >4 s.** Live upsd here stalls a
|
||||
single poll past `NUT_TIMEOUT_SECONDS` roughly every 2–4 minutes and then answers normally
|
||||
again; `nut` reads `ok` throughout, because the poller's job is one amber line plus the
|
||||
recovery, not a frozen page (invariant 4). What phase 12's verification pass fixed on our
|
||||
side was the **cascade** that used to follow it — the event table filling with
|
||||
`expected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"'`, which blamed the wire
|
||||
protocol for a stall two commands earlier (see the `NutClient` bullet above). The stall
|
||||
itself is an upsd/driver-side question with no repo bug in it, and a battery test is not
|
||||
how to chase it.
|
||||
Editing `upsd.users` at all: never grant `actions = -`, and never write it blind — the
|
||||
running `upsmon` authenticates as its own account from that same file for both UPSes, so
|
||||
dropping it means no orderly power-down. Copy the untouched sections from the live bytes
|
||||
@@ -1118,16 +1158,16 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
3. **UPS history depth.** Events and the numeric series are stored; long-window battery
|
||||
health (capacity fade over months) has the rollups but no dedicated view.
|
||||
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~640 MB on
|
||||
2026-10-19, its WAL grown past 140 MB at its worst) logs `store-error: OperationalError:
|
||||
database is locked` a few times an
|
||||
hour across `system`/`llm`/`ups`; the poller handles it exactly as invariant 4 requires —
|
||||
the source goes red with the real reason, ages, and writes `recording` when the next poll
|
||||
lands — so nothing freezes, but those polls' samples are lost. The one cause this repo can
|
||||
prove is two servers on one file, which is why `scripts/dev-server.sh stop` waits for the
|
||||
old pid to die (its comment records the incident). A controlled 25 s external read
|
||||
transaction during this pass produced none, and neither did the dev-server log window open on
|
||||
2026-10-19 — the absence of a sample, not the absence of the pressure, since the file only
|
||||
keeps growing.
|
||||
2026-10-19, its WAL grown past 140 MB at its worst) logged `store-error: OperationalError:
|
||||
database is locked` a few times an hour across `system`/`llm`/`ups` through 2026-10-18; the
|
||||
poller handles it exactly as invariant 4 requires — the source goes red with the real reason,
|
||||
ages, and writes `recording` when the next poll lands — so nothing freezes, but those polls'
|
||||
samples are lost. The one cause this repo can prove is two servers on one file, which is why
|
||||
`scripts/dev-server.sh stop` waits for the old pid to die (its comment records the incident).
|
||||
A controlled 25 s external read transaction during the phase-09 pass produced none, and
|
||||
phase 12's re-check on 2026-10-19 found the 190 stored `store-error` rows all older than
|
||||
2026-10-18 06:33 with none in the previous 24 h of the running dev store — the absence of a
|
||||
sample, not the absence of the pressure, since the file only keeps growing.
|
||||
Retention/`wal_checkpoint` tuning for a long-running store is its own phase, not a
|
||||
phase-09 follow-up.
|
||||
5. **The login throttle keys on the socket peer address** (phase 11), and the one
|
||||
|
||||
@@ -534,7 +534,13 @@ auth.py ── verifier + session key, loaded once at startup (no row and no see
|
||||
`severity` dict, with the electrical readings (line in, output, battery volts and amps,
|
||||
battery °C) kept below them in smaller muted type because they are what you read *while* the
|
||||
UPS is on battery. Health checks, the battery-test buttons (with their `DANGER`
|
||||
confirmation) and the NUT variable dump are unchanged, just second.
|
||||
confirmation) and the NUT variable dump are unchanged, just second. The `Last self-test`
|
||||
check grades the **outcome** of the driver's word and not its phrasing: a pass
|
||||
(`Done and passed`, the sentence this fleet's CyberPower answers) reads `ok` and raises no
|
||||
overview warning, a fail reads `fail`, and a pack that has never been tested —
|
||||
`No test initiated` or no variable at all — says the same "run a battery test" sentence as
|
||||
`unknown`. Only a word the map has never seen stays a warning, printed verbatim so it can
|
||||
be added.
|
||||
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
|
||||
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
|
||||
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
|
||||
@@ -614,8 +620,10 @@ battery glyph (`charge %`, `load %`, `runtime left`, `battery_voltage`, the glyp
|
||||
verbatim as `tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, with the
|
||||
device serials scrubbed — and again against live upsd. The captures earn their place: the
|
||||
CyberPower answers `battery.mfr.date: CPS`, the APC's `battery.date` is a factory
|
||||
placeholder, and **neither** publishes `battery.temperature` or `battery.charger.status`, so
|
||||
a hand-written variable table would have tested nobody's memory. `tests/slots_contract.py`
|
||||
placeholder, **neither** publishes `battery.temperature` or `battery.charger.status`, and the
|
||||
two disagree on `ups.test.result` (the CyberPower has run a test and answers `Done and passed`,
|
||||
the APC still answers the factory `No test initiated`), so a hand-written variable table would
|
||||
have tested nobody's memory. `tests/slots_contract.py`
|
||||
pins gufo's `/slots` rows the same way — hermetically from
|
||||
`tests/fixtures/slots_gufo.json` (the verbatim `/slots` reply, captured whenever
|
||||
`GUFO_BASE_URL` is set; the idle-slot rows are the shape the grid must handle) and again
|
||||
|
||||
@@ -191,6 +191,10 @@ class NutMonitor:
|
||||
reading = nut.UpsReading(name=ups, description=entry.get("description", ""),
|
||||
ts=now)
|
||||
reading.vars = await client.list_vars(ups)
|
||||
# A refusal is tolerable per UPS (the card loses its buttons, the page says
|
||||
# why); a stall is not, and is not swallowed — `NutClient` marks a socket
|
||||
# whose reply stopped early as unusable, so the next read fails the poll
|
||||
# naming the stall instead of misreading the leftover lines.
|
||||
try:
|
||||
reading.commands = await client.list_cmds(ups)
|
||||
except nut.NutError:
|
||||
@@ -256,9 +260,11 @@ class NutMonitor:
|
||||
first_seen = reading.name not in self._test_results
|
||||
self._test_results[reading.name] = result
|
||||
if not first_seen: # the first sighting is not news, a change is
|
||||
passed = result.strip().lower() in ("ok", "passed", "pass", "completed")
|
||||
# Same outcome map the health check uses, so a driver's `Done and passed`
|
||||
# is not an amber line in the feed while the card beside it reads green.
|
||||
outcome = nut.self_test_outcome(result)
|
||||
out.append(Event(now, "ups", reading.name, "test",
|
||||
"info" if passed else "warn",
|
||||
"info" if outcome == "ok" else "warn",
|
||||
f"{reading.name}: self-test reported {result}"))
|
||||
return out
|
||||
|
||||
|
||||
@@ -52,6 +52,18 @@ class NutError(Exception):
|
||||
self.code = code
|
||||
|
||||
|
||||
#: The `_read_line` failures that mean "the rest of this reply may still be arriving". An
|
||||
#: upsd `ERR …` line is the *whole* reply, so a denied `LIST` leaves the connection clean —
|
||||
#: which is what lets `collectors.NutMonitor` tolerate one UPS's refused `LIST CMD` and go on
|
||||
#: reading the next. Anything else (a stall, a dropped socket, a line out of place) means a
|
||||
#: partial reply, and a partial reply is a real hazard here: this upsd answers every poll
|
||||
#: within 4 s except every few minutes, when one connection gets no answer at all, and the
|
||||
#: `CMD`/`RW` rows it had already sent used to be read as the *next* command's `BEGIN` — the
|
||||
#: poll died saying `expected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"'`, a
|
||||
#: sentence about the wire protocol, when the truth was a stall two commands earlier.
|
||||
_INCOMPLETE_CODES = frozenset({"TIMEOUT", "CLOSED", "UNEXPECTED"})
|
||||
|
||||
|
||||
#: upsd error tokens -> what the operator should actually do about them.
|
||||
ERROR_HINTS = {
|
||||
# Reached only on an *authenticated* session — an unauthenticated client gets
|
||||
@@ -104,6 +116,9 @@ class NutClient:
|
||||
self.password = password
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer = None
|
||||
# `(command, why it stopped)` for the last reply this socket did not read to its end.
|
||||
# Not `None` means the socket is spent: see `_INCOMPLETE_CODES` and `_mark_unread()`.
|
||||
self._unread: tuple[str, str] | None = None
|
||||
|
||||
async def __aenter__(self) -> NutClient:
|
||||
await self.connect()
|
||||
@@ -131,6 +146,7 @@ class NutClient:
|
||||
f"cannot reach upsd at {self.host}:{self.port} ({reason})",
|
||||
code="UNREACHABLE",
|
||||
) from exc
|
||||
self._unread = None # a fresh socket has nothing of anybody's left on it
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._writer is None:
|
||||
@@ -146,9 +162,22 @@ class NutClient:
|
||||
self._reader = None
|
||||
|
||||
# -- plumbing ------------------------------------------------------------ #
|
||||
def _mark_unread(self, command: str, exc: NutError) -> None:
|
||||
"""Remember a reply that stopped early, so this socket is never asked another question."""
|
||||
if exc.code in _INCOMPLETE_CODES and self._unread is None:
|
||||
self._unread = (command, str(exc))
|
||||
|
||||
async def _send(self, command: str) -> None:
|
||||
if self._writer is None:
|
||||
raise NutError("not connected", code="NOCONNECTION")
|
||||
if self._unread is not None:
|
||||
stale, reason = self._unread
|
||||
raise NutError(
|
||||
f"this upsd connection is unusable: the reply to {stale!r} stopped early "
|
||||
f"({reason}), and its remaining lines would be read as the answer to "
|
||||
f"{command.split(None, 1)[0]!r}",
|
||||
code="UNUSABLE",
|
||||
)
|
||||
self._writer.write((command + "\n").encode())
|
||||
await self._writer.drain()
|
||||
|
||||
@@ -180,6 +209,7 @@ class NutClient:
|
||||
begin = f"BEGIN LIST {kind}"
|
||||
end = f"END LIST {kind}"
|
||||
await self._send(command)
|
||||
try:
|
||||
# LIST replies begin with `BEGIN LIST <kind>`, not `OK`, so this path must not
|
||||
# demand an OK line — upsd would otherwise be "broken" on every read.
|
||||
first = await self._read_line(expect_ok=False)
|
||||
@@ -193,6 +223,9 @@ class NutClient:
|
||||
rows.append(_split_fields(line))
|
||||
if len(rows) > 4096: # a wedged upsd must not grow the heap
|
||||
raise NutError(f"{kind} list too long", code="UNEXPECTED")
|
||||
except NutError as exc:
|
||||
self._mark_unread(command, exc)
|
||||
raise
|
||||
|
||||
async def login(self) -> bool:
|
||||
"""Authenticate for actions. Returns False when no credentials are configured.
|
||||
@@ -254,8 +287,15 @@ class NutClient:
|
||||
if not (self.username and self.password):
|
||||
raise NutError("instant commands need NUT_USER/NUT_PASSWORD with `actions` "
|
||||
"in upsd.users", code="ACCESS-DENIED")
|
||||
await self._send(f"INSTCMD {_quote(ups)} {_quote(command)}")
|
||||
sent = f'INSTCMD {_quote(ups)} {_quote(command)}'
|
||||
await self._send(sent)
|
||||
try:
|
||||
return await self._read_line()
|
||||
except NutError as exc:
|
||||
# A stall here means upsd may still answer the command later; the socket cannot be
|
||||
# asked anything else, and an `ERR …` line (the whole reply) leaves it usable.
|
||||
self._mark_unread(sent, exc)
|
||||
raise
|
||||
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
@@ -483,6 +523,47 @@ def _check(name: str, status: str, detail: str, *, metric: str | None = None,
|
||||
return {"name": name, "status": status, "detail": detail, "metric": metric, "value": value}
|
||||
|
||||
|
||||
#: What the driver's `*.test.result` word means, matched lowercased and trimmed exactly as
|
||||
#: `self_test_outcome()` normalises it. The two live words came off this fleet's upsd on
|
||||
#: 2026-10-19 (`AGENTS.md` rule 7, pinned as `LIVE_SELF_TEST_WORDS` in `tests/test_nut.py`):
|
||||
#: the CyberPower answers `Done and passed` after a successful test, the APC has never run
|
||||
#: one and answers the factory `No test initiated`.
|
||||
SELF_TEST_OUTCOMES: dict[str, str] = {
|
||||
"ok": "ok",
|
||||
"passed": "ok",
|
||||
"pass": "ok",
|
||||
"completed": "ok",
|
||||
"done and passed": "ok",
|
||||
"failed": "fail",
|
||||
"fail": "fail",
|
||||
"error": "fail",
|
||||
"aborted": "fail",
|
||||
"test incomplete": "fail",
|
||||
# A pack nobody has ever self-tested is a suggestion to run one, not a fault, so it
|
||||
# lands in the same tier as a driver that exports no variable at all — both print
|
||||
# `NEVER_TESTED_DETAIL` rather than a warning nobody can act on.
|
||||
"no test initiated": "unknown",
|
||||
"": "unknown",
|
||||
}
|
||||
|
||||
#: The one sentence both "never tested" states say.
|
||||
NEVER_TESTED_DETAIL = "no self-test result reported — run a battery test"
|
||||
|
||||
|
||||
def self_test_outcome(result: str | None) -> str:
|
||||
"""Grade a driver's self-test word: `ok`, `fail`, `unknown`, or `warn` if unrecognised.
|
||||
|
||||
The verdict follows the *outcome*, not the phrasing: `Done and passed` is a pass even
|
||||
though it is not the literal `passed` this map was written with, and grading it as a
|
||||
warning put a healthy battery in the overview's Warnings panel. A word outside the map
|
||||
is the one thing that still earns `warn` — a driver inventing a verdict is upstream
|
||||
drift worth noticing, and it keeps printing its own word so the operator can add it.
|
||||
"""
|
||||
if result is None:
|
||||
return "unknown"
|
||||
return SELF_TEST_OUTCOMES.get(result.strip().lower(), "warn")
|
||||
|
||||
|
||||
def assess(reading: UpsReading, *, now: float | None = None,
|
||||
battery_replacement_years: float = 4.0, min_runtime_seconds: float = 180.0) -> dict[str, Any]:
|
||||
"""A list of explicit checks, not a mystery score.
|
||||
@@ -556,13 +637,11 @@ def assess(reading: UpsReading, *, now: float | None = None,
|
||||
checks.append(_check("Battery age", "ok", f"{age:.1f} years", value=age))
|
||||
|
||||
result = reading.get("test_result")
|
||||
if result is None:
|
||||
checks.append(_check("Last self-test", "unknown",
|
||||
"no self-test result reported — run a battery test"))
|
||||
outcome = self_test_outcome(result)
|
||||
if outcome == "unknown":
|
||||
checks.append(_check("Last self-test", "unknown", NEVER_TESTED_DETAIL))
|
||||
else:
|
||||
passed = result.strip().lower() in ("ok", "passed", "pass", "completed")
|
||||
failed = result.strip().lower() in ("failed", "fail", "error", "aborted", "test incomplete")
|
||||
checks.append(_check("Last self-test", "ok" if passed else "fail" if failed else "warn",
|
||||
checks.append(_check("Last self-test", outcome,
|
||||
f"{result}" + (f" ({reading.get('test_date')})" if reading.get("test_date") else "")))
|
||||
|
||||
raw_temp = reading.number("battery_temperature")
|
||||
|
||||
Vendored
+4
-4
@@ -9,7 +9,7 @@ VAR apc battery.charge.low "10"
|
||||
VAR apc battery.charge.warning "50"
|
||||
VAR apc battery.date "2001/09/25"
|
||||
VAR apc battery.mfr.date "2021/06/24"
|
||||
VAR apc battery.runtime "633"
|
||||
VAR apc battery.runtime "621"
|
||||
VAR apc battery.runtime.low "120"
|
||||
VAR apc battery.type "PbAc"
|
||||
VAR apc battery.voltage "27.3"
|
||||
@@ -36,13 +36,13 @@ VAR apc driver.version.usb "libusb-1.0.30 (API: 0x0100010C)"
|
||||
VAR apc input.sensitivity "medium"
|
||||
VAR apc input.transfer.high "144"
|
||||
VAR apc input.transfer.low "88"
|
||||
VAR apc input.voltage "123.0"
|
||||
VAR apc input.voltage "124.0"
|
||||
VAR apc input.voltage.nominal "120"
|
||||
VAR apc ups.beeper.status "disabled"
|
||||
VAR apc ups.beeper.status "enabled"
|
||||
VAR apc ups.delay.shutdown "20"
|
||||
VAR apc ups.firmware "952.e5 .D"
|
||||
VAR apc ups.firmware.aux "e5 "
|
||||
VAR apc ups.load "50"
|
||||
VAR apc ups.load "46"
|
||||
VAR apc ups.mfr "American Power Conversion"
|
||||
VAR apc ups.mfr.date "2021/01/08"
|
||||
VAR apc ups.model "Back-UPS RS 1500MS"
|
||||
|
||||
Vendored
+2
-2
@@ -49,11 +49,11 @@ VAR cyberpower ups.model "GX1500U"
|
||||
VAR cyberpower ups.power "132"
|
||||
VAR cyberpower ups.power.nominal "1500"
|
||||
VAR cyberpower ups.productid "0601"
|
||||
VAR cyberpower ups.realpower "129"
|
||||
VAR cyberpower ups.realpower "126"
|
||||
VAR cyberpower ups.realpower.nominal "900"
|
||||
VAR cyberpower ups.serial "SCRUBBEDSERIAL1"
|
||||
VAR cyberpower ups.status "OL"
|
||||
VAR cyberpower ups.test.result "No test initiated"
|
||||
VAR cyberpower ups.test.result "Done and passed"
|
||||
VAR cyberpower ups.timer.shutdown "-60"
|
||||
VAR cyberpower ups.timer.start "-60"
|
||||
VAR cyberpower ups.vendorid "0764"
|
||||
|
||||
@@ -382,6 +382,171 @@ def test_snapshot_payloads_carry_their_own_severity_sections(data_client) -> Non
|
||||
assert isinstance(card["numbers"], dict) and "charge_pct" in card["numbers"]
|
||||
|
||||
|
||||
# ---- the self-test verdict on the two surfaces that were wrong (phase 12) --- #
|
||||
|
||||
#: The `ups.test.result` sentences as the hardware answers them. `Done and passed` is the
|
||||
#: CyberPower's verbatim word from the 2026-10-19 capture (`tests/test_nut.py
|
||||
#: ::LIVE_SELF_TEST_WORDS`); `FAILED` is what a driver says when a test ran and did not pass,
|
||||
#: and is the reason the check exists at all. `tests/test_nut.py` pins the *word map*; what is
|
||||
#: pinned here is the surface `TODO.md:3` names — a warning in the overview's Warnings panel —
|
||||
#: because a verdict that grades correctly inside `assess()` and still reaches the alert feed
|
||||
#: as a warning is the same bug wearing a different hat.
|
||||
SELF_TEST_PASS_WORD = "Done and passed"
|
||||
SELF_TEST_FAIL_WORD = "FAILED"
|
||||
|
||||
#: How `/api/summary` says the check out loud (`alerts._ups_alerts`): `"<check name>: <detail>"`.
|
||||
SELF_TEST_ALERT_TEXT = "Last self-test"
|
||||
|
||||
|
||||
def set_self_test_verdict(payload: dict[str, Any], status: str, detail: str) -> None:
|
||||
"""Rewrite every card's `Last self-test` check in the hub's own UPS payload.
|
||||
|
||||
Mutating the payload the job actually serves (instead of hand-building a fake one) is what
|
||||
makes this an end-to-end pin: `/api/ups/snapshot` and `alerts.build()` then read the *same*
|
||||
object, so the test cannot pass by having the two surfaces disagree about the verdict.
|
||||
"""
|
||||
graded = 0
|
||||
for card in payload["ups"]:
|
||||
for check in card["health"]["checks"]:
|
||||
if check["name"] == SELF_TEST_ALERT_TEXT:
|
||||
check["status"] = status
|
||||
check["detail"] = detail
|
||||
graded += 1
|
||||
assert graded, "no `Last self-test` check in the UPS payload — nothing was tested"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def self_test_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[tuple[TestClient, dict[str, Any]]]:
|
||||
"""A UPS app whose served payload cannot be replaced by a poll mid-assertion.
|
||||
|
||||
The simulated UPSes answer `PASSED`, so the pass direction would survive a race with the
|
||||
poll loop and the *fail* direction would not — a test that flakes on a poll is a test of
|
||||
timing. Hence `healthy_client`'s trick for the `nut` job: the collector hands back the very
|
||||
dict this harness holds, so the health record says `ok` truthfully (invariant 4) and the
|
||||
payload stays ours. The interval is shortened for the same reason — waiting a real
|
||||
`NUT_INTERVAL_SECONDS` per test would only make the suite slower at proving the same thing.
|
||||
"""
|
||||
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
|
||||
nut_interval=0.5) as test_client:
|
||||
from dashboards.poller import Result # noqa: PLC0415 - only this harness needs it
|
||||
|
||||
hub = test_client.app.state.ctx.hub # type: ignore[attr-defined]
|
||||
job = hub.jobs["nut"]
|
||||
wait_for_payload(test_client, "/api/ups/snapshot")
|
||||
payload = job.state.payload
|
||||
|
||||
async def collect() -> Result:
|
||||
return Result(payload=payload, samples=[])
|
||||
|
||||
job.fn = collect
|
||||
# let one poll of *our* collector land before the caller mutates the payload, so the
|
||||
# health record and the served bytes are both demonstrably ours
|
||||
baseline = job.state.attempts
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline and job.state.attempts <= baseline:
|
||||
time.sleep(0.02)
|
||||
assert job.state.payload is payload and job.state.ok, (
|
||||
"the nut job is not serving the payload under test — the pin below would be fiction"
|
||||
)
|
||||
yield test_client, payload
|
||||
|
||||
|
||||
def self_test_alerts(alerts_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [item for item in alerts_list
|
||||
if SELF_TEST_ALERT_TEXT.lower() in item["message"].lower()
|
||||
or SELF_TEST_ALERT_TEXT.lower() in item["id"].lower()]
|
||||
|
||||
|
||||
def test_a_passed_self_test_is_quiet_on_the_overview_and_ok_on_the_ups_page(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The TODO's sentence, end to end: `Done and passed` is not a warning anywhere.
|
||||
|
||||
Both halves of the fix are asserted on one payload: `/api/ups/snapshot` carries the `ok`
|
||||
entry the `/ups` check list renders verbatim, and `/api/summary` — the only call the
|
||||
overview makes — carries **no** row naming it. The simulated card still contributes its
|
||||
`NUT_SIMULATE` line, so the list is not empty and this cannot be a vacuous pass.
|
||||
"""
|
||||
with self_test_app(tmp_path, monkeypatch) as (test_client, payload):
|
||||
set_self_test_verdict(payload, "ok", SELF_TEST_PASS_WORD)
|
||||
cards = wait_for_payload(test_client, "/api/ups/snapshot")["ups"]
|
||||
for card in cards:
|
||||
check = next(c for c in card["health"]["checks"]
|
||||
if c["name"] == SELF_TEST_ALERT_TEXT)
|
||||
assert check["status"] == "ok", f"{card['name']} reports a pass as {check['status']}"
|
||||
assert check["detail"] == SELF_TEST_PASS_WORD, "the driver's word is the useful half"
|
||||
rows = self_test_alerts(test_client.get("/api/summary").json()["alerts"])
|
||||
assert rows == [], f"a passed battery test is on the overview as {rows}"
|
||||
assert any(item["source"] == "ups" for item in
|
||||
test_client.get("/api/summary").json()["alerts"]), (
|
||||
"the simulated-UPS line disappeared, so this assertion has nothing left to be quiet about"
|
||||
)
|
||||
|
||||
|
||||
def test_a_never_tested_battery_is_not_an_alert_either(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The APC's factory `No test initiated` reaches the overview as silence, not amber.
|
||||
|
||||
This is the state the live hardware is actually in on one of the two UPSes, so the
|
||||
screenshot proves the absence of a warning for a *never-run* test while the payload pin
|
||||
above proves it for a passed one. `unknown` is not a verdict an operator can act on, and
|
||||
`alerts._RANK` admits only `fail` and `warn`.
|
||||
"""
|
||||
with self_test_app(tmp_path, monkeypatch) as (test_client, payload):
|
||||
set_self_test_verdict(payload, "unknown", nut.NEVER_TESTED_DETAIL)
|
||||
cards = wait_for_payload(test_client, "/api/ups/snapshot")["ups"]
|
||||
assert all(next(c for c in card["health"]["checks"]
|
||||
if c["name"] == SELF_TEST_ALERT_TEXT)["status"] == "unknown"
|
||||
for card in cards)
|
||||
assert self_test_alerts(test_client.get("/api/summary").json()["alerts"]) == []
|
||||
|
||||
|
||||
def test_a_failed_self_test_still_reaches_the_overview(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The check keeps its reason for existing: a test that failed is a red line, once per UPS.
|
||||
|
||||
Without this direction the absence test above is satisfiable by deleting the check, which
|
||||
is the failure mode the phase's completion criterion calls out.
|
||||
"""
|
||||
with self_test_app(tmp_path, monkeypatch) as (test_client, payload):
|
||||
set_self_test_verdict(payload, "fail", SELF_TEST_FAIL_WORD)
|
||||
names = {card["name"] for card in
|
||||
wait_for_payload(test_client, "/api/ups/snapshot")["ups"]}
|
||||
assert names, "no UPS card to fail"
|
||||
rows = self_test_alerts(test_client.get("/api/summary").json()["alerts"])
|
||||
assert {row["source"] for row in rows} == names
|
||||
for row in rows:
|
||||
assert row["severity"] == "fail", f"a failed battery test reads {row['severity']}"
|
||||
assert row["href"] == "/ups", "the alert must link to the card that names the UPS"
|
||||
assert SELF_TEST_ALERT_TEXT in row["message"] and SELF_TEST_FAIL_WORD in row["message"]
|
||||
assert row["message"].count(SELF_TEST_ALERT_TEXT) == 1, (
|
||||
"the row names the check twice — see alerts._clean_error")
|
||||
assert all(row["id"] == f"{row['source']}:health:{SELF_TEST_ALERT_TEXT}" for row in rows)
|
||||
|
||||
|
||||
def test_the_ups_check_list_needs_no_new_markup_for_a_passed_verdict(client) -> None:
|
||||
"""Task 03 expected no page change, and this pins *why* there is none.
|
||||
|
||||
`page-ups.js` puts `check.status` straight on the `<li>`, and `dash.css` colours only the
|
||||
`.mark` glyph from the shared `--ok`/`--warn`/`--fail` tokens while the check's name and
|
||||
detail stay `--text`/`--text-muted` — so an `ok` row is quiet ink with a tick, and a verdict
|
||||
fixed in `assess()` is a verdict rendered. Nothing on either asset names the self-test
|
||||
check, which is the point: a special case added here would be a second opinion about the
|
||||
driver's word, and the API already made it.
|
||||
"""
|
||||
js = _code(client.get("/static/page-ups.js").text)
|
||||
assert '<li class="${check.status}">' in js, "the row no longer wears the payload verdict as its class"
|
||||
for verdict in ("ok", "warn", "fail", "unknown"):
|
||||
assert f"{verdict}: '" in js[js.index("const MARKS"):], f"MARKS lost the {verdict} glyph"
|
||||
rows = js[js.index("const health = item.health"):js.index("const commands = item.commands")]
|
||||
assert "self-test" not in rows.lower(), "the check list grew an opinion about one check by name"
|
||||
css = _code(_css(client))
|
||||
ok_rule = re.search(r"ul\.checks li\.ok \.mark \{([^}]*)\}", css)
|
||||
assert ok_rule and "var(--ok)" in ok_rule.group(1), "the ok tick is not the shared token"
|
||||
assert "ul.checks li.ok {" not in css, "the whole ok row is coloured — a pass is not a banner"
|
||||
assert "self-test" not in css.lower(), "the stylesheet special-cases one check by name"
|
||||
|
||||
|
||||
# ---- unconfigured: say so, and do not dial -------------------------------- #
|
||||
|
||||
def test_config_reports_every_source_as_off(client) -> None:
|
||||
|
||||
@@ -557,6 +557,11 @@ async def test_a_self_test_result_is_reported_when_it_changes() -> None:
|
||||
assert events[0].severity == "warn"
|
||||
monitor.simulator = FakeSimulator([reading("OL", **{"ups.test.result": "OK"})])
|
||||
assert (await monitor.collect()).events[0].severity == "info"
|
||||
# the word this fleet's CyberPower actually answers (phase 12 capture): the feed and
|
||||
# the health check grade one outcome, so a passed test is never an amber line
|
||||
monitor.simulator = FakeSimulator(
|
||||
[reading("OL", **{"ups.test.result": "Done and passed"})])
|
||||
assert (await monitor.collect()).events[0].severity == "info"
|
||||
|
||||
|
||||
async def test_gating_is_applied_where_the_commands_are_built() -> None:
|
||||
|
||||
+264
-2
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -16,6 +17,7 @@ import pytest_asyncio
|
||||
from dashboards.sources import nut
|
||||
from dashboards.sources.nut import NutClient, NutError, UpsReading, normalise_celsius
|
||||
from dashboards.sources.nut_sim import NutSimulator, SimUps, from_env
|
||||
from ups_contract import parse_capture
|
||||
|
||||
VAR_ROWS = {
|
||||
"battery.charge": "100",
|
||||
@@ -47,10 +49,18 @@ class FakeUpsd:
|
||||
"""Just enough of upsd's protocol to exercise the client, over a real socket."""
|
||||
|
||||
def __init__(self, *, require_auth: bool = False, close_early: bool = False,
|
||||
hang: bool = False) -> None:
|
||||
hang: bool = False, partial_list: str | None = None,
|
||||
deny: tuple[str, ...] = ()) -> None:
|
||||
self.require_auth = require_auth
|
||||
self.close_early = close_early
|
||||
self.hang = hang
|
||||
# Answer this `LIST` kind with `BEGIN` + one row and then go silent: the row is already
|
||||
# in the client's buffer when its read times out, which is the exact state a slow upsd
|
||||
# leaves a shared connection in.
|
||||
self.partial_list = partial_list
|
||||
# Answer these `LIST` kinds with a complete `ERR` line — a refusal is the whole reply,
|
||||
# so a client may (and must) keep using the connection afterwards.
|
||||
self.deny = deny
|
||||
self.received: list[str] = []
|
||||
self.authenticated = False
|
||||
self._server: asyncio.AbstractServer | None = None
|
||||
@@ -84,7 +94,16 @@ class FakeUpsd:
|
||||
|
||||
if verb == "LIST":
|
||||
kind, _, target = rest.partition(" ")
|
||||
if kind == "UPS":
|
||||
if kind in self.deny:
|
||||
send("ERR ACCESS-DENIED")
|
||||
elif kind == self.partial_list:
|
||||
# One row of the real shape, then silence: no END, so the client times out
|
||||
# with this row sitting in its socket buffer.
|
||||
send(f"BEGIN LIST {kind}")
|
||||
send(f'VAR "{_strip(target)}" "ups.status" "OL"' if kind == "VAR"
|
||||
else f'{kind} "{_strip(target)}" "beeper.mute"')
|
||||
return
|
||||
elif kind == "UPS":
|
||||
send("BEGIN LIST UPS")
|
||||
send('UPS "cyberpower" "Cyber Power Systems PR1500LCDRT2U"')
|
||||
send("END LIST UPS")
|
||||
@@ -301,6 +320,77 @@ async def test_a_silent_upsd_times_out_instead_of_hanging() -> None:
|
||||
await server.stop()
|
||||
|
||||
|
||||
async def test_a_reply_that_stops_early_makes_the_socket_refuse_the_next_command() -> None:
|
||||
"""A stalled `LIST` is reported as the stall it was, not as a protocol mystery.
|
||||
|
||||
This fleet's upsd answers every poll within `NUT_TIMEOUT_SECONDS` except every so often,
|
||||
and the lines it had already sent stay in the socket buffer. `collectors.NutMonitor`
|
||||
tolerates a failed `LIST CMD`/`LIST RW` and reads the next UPS on the same connection, so
|
||||
those leftovers used to be taken for that command's `BEGIN` and the poll died saying
|
||||
`expected 'BEGIN LIST VAR', got 'RW apc battery.charge.low "10"'` — a sentence about the
|
||||
wire protocol, when the truth was a 4-second stall two commands earlier (the pair of
|
||||
messages is in the dev `event` table, which is how this got noticed).
|
||||
"""
|
||||
server = FakeUpsd(partial_list="CMD")
|
||||
port = await server.start()
|
||||
try:
|
||||
async with NutClient("127.0.0.1", port, timeout=0.3) as client:
|
||||
with pytest.raises(NutError) as stalled:
|
||||
await client.list_cmds("apc")
|
||||
assert stalled.value.code == "TIMEOUT"
|
||||
with pytest.raises(NutError) as next_command:
|
||||
await client.list_vars("apc")
|
||||
finally:
|
||||
await server.stop()
|
||||
assert next_command.value.code == "UNUSABLE"
|
||||
assert "did not answer" in str(next_command.value), "the cause is named, not the symptom"
|
||||
assert "LIST CMD" in str(next_command.value), "and it names the command that stalled"
|
||||
|
||||
|
||||
async def test_an_instant_command_that_never_answers_spends_the_connection() -> None:
|
||||
"""The guard is on the write path too, where it matters most.
|
||||
|
||||
`instcmd` is the only thing in this app that dials a write. If upsd answers it after we
|
||||
gave up, that late `OK` sits on the socket, and a client allowed to ask another question
|
||||
would read a *different* command's success as the answer to it — the one mistake the four
|
||||
gates cannot survive, since it would print `OK` beside a `load.off` nobody confirmed.
|
||||
"""
|
||||
server = FakeUpsd(hang=True)
|
||||
port = await server.start()
|
||||
try:
|
||||
async with NutClient("127.0.0.1", port, timeout=0.3,
|
||||
username="monitor", password="pw") as client:
|
||||
with pytest.raises(NutError) as hung:
|
||||
await client.instcmd("apc", "beeper.mute")
|
||||
assert hung.value.code == "TIMEOUT"
|
||||
with pytest.raises(NutError) as next_command:
|
||||
await client.instcmd("apc", "load.off")
|
||||
finally:
|
||||
await server.stop()
|
||||
assert next_command.value.code == "UNUSABLE"
|
||||
assert "INSTCMD" in str(next_command.value)
|
||||
|
||||
|
||||
async def test_an_upsd_error_line_leaves_the_connection_usable() -> None:
|
||||
"""The other half: `ERR …` is a *complete* reply, so the tolerance in `collect()` stands.
|
||||
|
||||
One UPS answering `LIST CMD` with `ACCESS-DENIED` must cost that UPS its command buttons
|
||||
and nothing else — the same poll keeps reading the other battery. A guard that poisoned the
|
||||
connection on a refusal would turn a per-UPS refusal into a dead UPS page.
|
||||
"""
|
||||
server = FakeUpsd(deny=("CMD",))
|
||||
port = await server.start()
|
||||
try:
|
||||
async with NutClient("127.0.0.1", port, timeout=2.0) as client:
|
||||
with pytest.raises(NutError) as denied:
|
||||
await client.list_cmds("apc")
|
||||
assert denied.value.code == "ACCESS-DENIED"
|
||||
variables = await client.list_vars("apc")
|
||||
finally:
|
||||
await server.stop()
|
||||
assert variables["ups.status"] == "OL CHRG"
|
||||
|
||||
|
||||
# ---- reading semantics ---------------------------------------------------- #
|
||||
|
||||
def reading(**overrides: str) -> UpsReading:
|
||||
@@ -407,6 +497,178 @@ def test_missing_test_history_is_unknown_not_ok() -> None:
|
||||
assert check["status"] == "unknown"
|
||||
|
||||
|
||||
# ---- the self-test word the hardware actually answers (phase 12 pin) ------- #
|
||||
|
||||
#: `ups.test.result` exactly as the two real UPSes answered it, captured from the running
|
||||
#: upsd on **2026-10-19** from the dev box (which reaches driveripper's 3051 — the transport
|
||||
#: phase 10 re-verified) with::
|
||||
#:
|
||||
#: printf 'LIST VAR apc ups.test.result\nLIST VAR cyberpower ups.test.result\n' |
|
||||
#: nc <upsd-host> 3051
|
||||
#:
|
||||
#: These are the sentences the "Last self-test" check has to grade, and they are not the
|
||||
#: words its pass tuple was written for (AGENTS.md rule 7 — verify against the deployed
|
||||
#: upstream): the CyberPower has had a test run and answers `Done and passed`, the APC has
|
||||
#: not and still answers the factory `No test initiated`. The same two words are what
|
||||
#: `/api/summary` was putting in the Warnings panel on the capture date, one per UPS.
|
||||
#: Re-captured with `./scripts/capture-fixtures.sh` the same day, so `nut_*.vars` carries
|
||||
#: them verbatim; if a future capture changes one, the first test below fails and the word
|
||||
#: map and this table get updated together.
|
||||
LIVE_SELF_TEST_WORDS = {
|
||||
"apc": "No test initiated",
|
||||
"cyberpower": "Done and passed",
|
||||
}
|
||||
|
||||
|
||||
def self_test_check(health: dict) -> dict:
|
||||
return next(check for check in health["checks"] if check["name"] == "Last self-test")
|
||||
|
||||
|
||||
def test_the_captured_self_test_words_are_what_the_fixtures_carry() -> None:
|
||||
"""The capture and the verbatim fixture must not drift apart.
|
||||
|
||||
`assess()` is asserted against `LIVE_SELF_TEST_WORDS` below, so a fixture re-captured
|
||||
after somebody runs a real battery test has to land in the same table — the day the two
|
||||
disagree, the verdict is being tested against a memory of the hardware instead of the
|
||||
hardware (`AGENTS.md` rule 7).
|
||||
"""
|
||||
for ups, word in LIVE_SELF_TEST_WORDS.items():
|
||||
capture = (Path(__file__).parent / "fixtures" / f"nut_{ups}.vars").read_text()
|
||||
assert f'VAR {ups} ups.test.result "{word}"' in capture, (
|
||||
f"nut_{ups}.vars no longer answers the captured word {word!r} — re-run "
|
||||
f"./scripts/capture-fixtures.sh and update LIVE_SELF_TEST_WORDS with the diff"
|
||||
)
|
||||
assert parse_capture(ups).get("test_result") == word
|
||||
|
||||
|
||||
def test_a_passed_self_test_reads_ok_and_not_a_warning() -> None:
|
||||
"""The task-01 pin, now green: the CyberPower's pass word must read `ok`.
|
||||
|
||||
Before phase 12 `assess()` compared the driver's word against
|
||||
`("ok", "passed", "pass", "completed")`, so `Done and passed` fell through to `warn` and
|
||||
the overview printed a warning for a battery that passed.
|
||||
"""
|
||||
health = nut.assess(parse_capture("cyberpower"), now=1_767_225_600.0)
|
||||
check = self_test_check(health)
|
||||
assert check["status"] == "ok", (
|
||||
f"the driver reported {check['detail']!r} and it graded {check['status']!r}"
|
||||
)
|
||||
assert check["detail"].startswith(LIVE_SELF_TEST_WORDS["cyberpower"]), (
|
||||
"the driver's own sentence is the useful part of this line")
|
||||
|
||||
|
||||
def test_a_self_test_that_was_never_initiated_is_not_a_warning() -> None:
|
||||
"""The second half of the same pin: the APC's factory state is not a fault.
|
||||
|
||||
A pack nobody has ever self-tested is a suggestion to run one, so it reads `unknown`
|
||||
with the unset branch's sentence instead of `warn`.
|
||||
"""
|
||||
health = nut.assess(parse_capture("apc"), now=1_767_225_600.0)
|
||||
check = self_test_check(health)
|
||||
assert check["status"] == "unknown", (
|
||||
f"never tested ({check['detail']!r}) must read like the unset case, not "
|
||||
f"{check['status']!r}"
|
||||
)
|
||||
assert check["detail"] == nut.NEVER_TESTED_DETAIL
|
||||
assert "run a battery test" in check["detail"]
|
||||
|
||||
|
||||
#: The whole `Last self-test` outcome map: the driver's word -> the verdict `assess()` gives.
|
||||
#: The pass tier holds every pass word this fleet's drivers, the simulator, and the older
|
||||
#: NUT drivers answer — including `Done and passed`, task 01's live capture and the one the
|
||||
#: old tuple missed. The fail tier is unchanged: a test that ran and failed is the reason the
|
||||
#: check exists. `unknown` holds both "no test has ever run" states. `warn` is the deliberate
|
||||
#: fall-through for a word this map has never seen — a driver inventing a verdict is upstream
|
||||
#: drift worth a line, and the check prints that word verbatim so it can be added.
|
||||
SELF_TEST_WORD_MAP = {
|
||||
"ok": "ok",
|
||||
"passed": "ok",
|
||||
"pass": "ok",
|
||||
"completed": "ok",
|
||||
LIVE_SELF_TEST_WORDS["cyberpower"]: "ok",
|
||||
"FAILED": "fail",
|
||||
"fail": "fail",
|
||||
"Error": "fail",
|
||||
"aborted": "fail",
|
||||
"test incomplete": "fail",
|
||||
LIVE_SELF_TEST_WORDS["apc"]: "unknown",
|
||||
"": "unknown",
|
||||
"mystery": "warn",
|
||||
"DONE AND FAILED": "warn",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("word,expected", sorted(SELF_TEST_WORD_MAP.items()))
|
||||
def test_the_self_test_check_grades_the_outcome_and_not_the_phrasing(word, expected) -> None:
|
||||
check = self_test_check(nut.assess(reading(**{"battery.test.result": word}),
|
||||
now=1_767_225_600.0))
|
||||
assert check["status"] == expected, f"{word!r} graded {check['status']!r}"
|
||||
if expected == "unknown":
|
||||
assert check["detail"] == nut.NEVER_TESTED_DETAIL
|
||||
else:
|
||||
assert check["detail"].startswith(word), "the raw word is what the operator reads"
|
||||
assert "2026-09-01" in check["detail"], "the test_date suffix stays on the line"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", [
|
||||
"Done and passed", "DONE AND PASSED", "done and passed", " Done and passed ",
|
||||
])
|
||||
def test_the_live_pass_word_is_matched_case_and_whitespace_insensitively(variant) -> None:
|
||||
assert nut.self_test_outcome(variant) == "ok"
|
||||
check = self_test_check(nut.assess(reading(**{"battery.test.result": variant}),
|
||||
now=1_767_225_600.0))
|
||||
assert check["status"] == "ok"
|
||||
|
||||
|
||||
def test_never_tested_says_the_same_sentence_as_no_variable_at_all() -> None:
|
||||
"""`No test initiated` and a driver exporting nothing are one sentence, not two.
|
||||
|
||||
Both are "run a battery test": a check that invented a second phrasing for the same
|
||||
nothing would be two warnings to learn instead of one thing to do.
|
||||
"""
|
||||
values = {key: value for key, value in VAR_ROWS.items()
|
||||
if not key.startswith("battery.test")}
|
||||
unset = self_test_check(nut.assess(nut.UpsReading(name="apc", vars=values, commands=[]),
|
||||
now=1_767_225_600.0))
|
||||
never = self_test_check(nut.assess(reading(**{
|
||||
"battery.test.result": LIVE_SELF_TEST_WORDS["apc"]}), now=1_767_225_600.0))
|
||||
assert unset["status"] == never["status"] == "unknown"
|
||||
assert never["detail"] == unset["detail"] == nut.NEVER_TESTED_DETAIL
|
||||
|
||||
|
||||
def test_no_word_the_real_drivers_answer_lands_in_warn() -> None:
|
||||
"""Whatever the two boxes answer must be a word the map knows.
|
||||
|
||||
`warn` is the fall-through, so a captured word reaching it means the overview is warning
|
||||
about a battery this code has simply never read before — the exact bug this phase fixes.
|
||||
"""
|
||||
for ups, word in LIVE_SELF_TEST_WORDS.items():
|
||||
check = self_test_check(nut.assess(parse_capture(ups), now=1_767_225_600.0))
|
||||
assert check["status"] != "warn", f"{ups} answers {word!r} and it graded warn"
|
||||
assert check["status"] == nut.self_test_outcome(word)
|
||||
|
||||
|
||||
def test_the_outcome_map_holds_only_verdicts_the_pages_render() -> None:
|
||||
"""A typo'd tier in the table would reach the browser as an unknown CSS class."""
|
||||
assert set(nut.SELF_TEST_OUTCOMES.values()) <= {"ok", "fail", "unknown"}
|
||||
assert nut.self_test_outcome(None) == "unknown", "no variable is not a verdict"
|
||||
|
||||
|
||||
def test_the_simulator_only_answers_words_the_map_grades() -> None:
|
||||
"""The model's `PASSED` / `FAILED` were already in the right tiers (task 02 item 4).
|
||||
|
||||
If the simulator ever learns a third word it goes into `SELF_TEST_OUTCOMES`; it gets no
|
||||
special case here, because a model the health check grades differently from the hardware
|
||||
is a dev box that lies about `/ups`.
|
||||
"""
|
||||
sim = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1")
|
||||
now = time.time()
|
||||
assert nut.self_test_outcome(sim.test_result) == "ok"
|
||||
assert nut.self_test_outcome("FAILED") == "fail"
|
||||
card = nut.payload(UpsReading(name=sim.name, vars=sim.vars(now), commands=[]), now=now)
|
||||
assert self_test_check(card["health"])["status"] == "ok"
|
||||
|
||||
|
||||
def test_alarms_drive_the_overall_verdict() -> None:
|
||||
health = nut.assess(reading(**{"ups.alarm": "BATT.FAIL", "battery.temperature": "27.4"}),
|
||||
now=1_767_225_600.0)
|
||||
|
||||
Reference in New Issue
Block a user