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`.
1018 lines
46 KiB
Python
1018 lines
46 KiB
Python
"""NUT: the wire protocol, the health rules, and the gates around the only write path.
|
|
|
|
The protocol tests run against a fake upsd over a real socket — this client speaks a
|
|
line protocol with quoting rules, and a mock that agrees with my memory of it proves
|
|
nothing. The health rules and the command gates are pure functions and are asserted
|
|
directly, because those are what decide whether a battery test gets sent.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
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",
|
|
"battery.runtime": "3720",
|
|
"battery.voltage": "27.3",
|
|
"battery.temperature": "300.4", # kelvin, as several drivers report it
|
|
"battery.date": "2021/04/01",
|
|
"battery.type": "Pb",
|
|
"input.voltage": "121.8",
|
|
"input.voltage.nominal": "120",
|
|
"input.transfer.low": "92",
|
|
"input.transfer.high": "139",
|
|
"output.voltage": "120.9",
|
|
"ups.load": "34",
|
|
"ups.realpower.nominal": "900",
|
|
"ups.load.high": "1500",
|
|
"ups.status": "OL CHRG",
|
|
"ups.mfr": "Cyber Power Systems",
|
|
"ups.model": "PR1500LCDRT2U",
|
|
"battery.test.result": "PASSED",
|
|
"battery.test.date": "2026-09-01",
|
|
}
|
|
|
|
CMDS = ["beeper.mute", "test.panel.start", "test.battery.start.quick",
|
|
"test.battery.start.deep", "load.off"]
|
|
|
|
|
|
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, 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
|
|
|
|
async def start(self) -> int:
|
|
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
|
|
await self._server.start_serving()
|
|
return int(self._server.sockets[0].getsockname()[1])
|
|
|
|
async def stop(self) -> None:
|
|
if self._server:
|
|
self._server.close()
|
|
await self._server.wait_closed()
|
|
|
|
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
if self.close_early:
|
|
writer.close()
|
|
return
|
|
while True:
|
|
raw = await reader.readline()
|
|
if not raw:
|
|
break
|
|
line = raw.decode().strip()
|
|
self.received.append(line)
|
|
verb, _, rest = line.partition(" ")
|
|
await self._respond(writer, verb, rest)
|
|
|
|
async def _respond(self, writer: asyncio.StreamWriter, verb: str, rest: str) -> None:
|
|
def send(text: str) -> None:
|
|
writer.write((text + "\n").encode())
|
|
|
|
if verb == "LIST":
|
|
kind, _, target = rest.partition(" ")
|
|
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")
|
|
elif kind == "VAR":
|
|
send("BEGIN LIST VAR")
|
|
for key, value in VAR_ROWS.items():
|
|
send(f'VAR "{_strip(target)}" "{key}" "{value}"')
|
|
send("END LIST VAR")
|
|
elif kind == "CMD":
|
|
send("BEGIN LIST CMD")
|
|
for command in CMDS:
|
|
send(f'CMD "{_strip(target)}" "{command}"')
|
|
send("END LIST CMD")
|
|
elif kind == "RW":
|
|
send("BEGIN LIST RW")
|
|
send(f'RW "{_strip(target)}" "battery.energysave" "0"')
|
|
send("END LIST RW")
|
|
else:
|
|
send("ERR UNKNOWN-COMMAND")
|
|
elif verb == "USERNAME":
|
|
self.user = rest
|
|
send("OK")
|
|
elif verb == "PASSWORD":
|
|
# The password has to matter: a fake that authenticates on the username alone
|
|
# cannot express a wrong-credential refusal, which is the commonest reason the
|
|
# command buttons stay dead after someone fills in NUT_PASSWORD.
|
|
self.authenticated = not self.require_auth or (
|
|
getattr(self, "user", "") == "monitor" and rest == "pw"
|
|
)
|
|
send("OK" if self.authenticated else "ERR ACCESS-DENIED")
|
|
# No USER/PASS branches on purpose: real upsd 2.8.5 has neither verb, and answering
|
|
# ERR UNKNOWN-COMMAND here (the fallthrough below) is what catches a client that
|
|
# starts sending them again.
|
|
elif verb == "INSTCMD":
|
|
if self.require_auth and not self.authenticated:
|
|
send("ERR ACCESS-DENIED")
|
|
elif self.hang:
|
|
await asyncio.sleep(30)
|
|
else:
|
|
send("OK Send shorthand command successful")
|
|
elif verb == "LOGOUT":
|
|
send("OK Goodbye")
|
|
else:
|
|
send("ERR UNKNOWN-COMMAND")
|
|
|
|
|
|
def _strip(value: str) -> str:
|
|
return value.strip().strip('"')
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def upsd():
|
|
server = FakeUpsd()
|
|
port = await server.start()
|
|
yield server, port
|
|
await server.stop()
|
|
|
|
|
|
async def test_list_ups_parses_names_and_descriptions(upsd) -> None:
|
|
_server, port = upsd
|
|
async with NutClient("127.0.0.1", port) as client:
|
|
ups_list = await client.list_ups()
|
|
assert ups_list[0]["name"] == "cyberpower"
|
|
assert ups_list[0]["description"] == "Cyber Power Systems PR1500LCDRT2U"
|
|
|
|
|
|
async def test_list_vars_keeps_spaces_and_quotes(upsd) -> None:
|
|
_server, port = upsd
|
|
async with NutClient("127.0.0.1", port) as client:
|
|
variables = await client.list_vars("cyberpower")
|
|
assert variables["ups.mfr"] == "Cyber Power Systems"
|
|
assert variables["battery.charge"] == "100"
|
|
assert variables["ups.status"] == "OL CHRG"
|
|
|
|
|
|
async def test_list_cmds_returns_what_the_driver_offers(upsd) -> None:
|
|
_server, port = upsd
|
|
async with NutClient("127.0.0.1", port) as client:
|
|
commands = await client.list_cmds("cyberpower")
|
|
assert commands == CMDS
|
|
|
|
|
|
async def test_list_rw_vars_names_the_variables_not_the_ups(upsd) -> None:
|
|
"""`RW <ups> <variable>`: reading field 1 returns the UPS name as a writable variable."""
|
|
_server, port = upsd
|
|
async with NutClient("127.0.0.1", port) as client:
|
|
rw = await client.list_rw_vars("cyberpower")
|
|
assert rw == ["battery.energysave"]
|
|
|
|
|
|
async def test_instcmd_without_credentials_refuses_locally(upsd) -> None:
|
|
"""No socket write: a dashboard must not even try when it knows it cannot authorise."""
|
|
_server, port = upsd
|
|
async with NutClient("127.0.0.1", port) as client:
|
|
with pytest.raises(NutError) as excinfo:
|
|
await client.instcmd("cyberpower", "test.panel.start")
|
|
assert excinfo.value.code == "ACCESS-DENIED"
|
|
assert "INSTCMD" not in _server.received or not any("INSTCMD" in line for line in _server.received)
|
|
|
|
|
|
async def test_instcmd_with_credentials_succeeds() -> None:
|
|
server = FakeUpsd(require_auth=True)
|
|
port = await server.start()
|
|
try:
|
|
async with NutClient("127.0.0.1", port, username="monitor", password="pw") as client:
|
|
assert await client.login() is True
|
|
reply = await client.instcmd("cyberpower", "test.panel.start")
|
|
assert "OK" in reply
|
|
assert any(line.startswith("INSTCMD") for line in server.received)
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
async def test_login_uses_the_verbs_upsd_2_8_5_answers_to() -> None:
|
|
"""The verbs are a protocol contract, not a style choice.
|
|
|
|
`USER`/`PASS` are not in the deployed upsd's `HELP` list and it answers them
|
|
`ERR UNKNOWN-COMMAND`, which made every login fail. This pins the pair that actually
|
|
authenticates, unquoted, and fails if either legacy verb comes back.
|
|
"""
|
|
server = FakeUpsd(require_auth=True)
|
|
port = await server.start()
|
|
try:
|
|
async with NutClient("127.0.0.1", port, username="monitor", password="pw") as client:
|
|
assert await client.login() is True
|
|
finally:
|
|
await server.stop()
|
|
assert server.received[:2] == ["USERNAME monitor", "PASSWORD pw"]
|
|
assert not any(line.split(" ")[0] in ("USER", "PASS") for line in server.received)
|
|
|
|
|
|
async def test_login_reports_a_rejected_password_as_an_upsd_error() -> None:
|
|
"""A bad credential must surface as the server's refusal, not a silent False."""
|
|
server = FakeUpsd(require_auth=True)
|
|
port = await server.start()
|
|
try:
|
|
async with NutClient("127.0.0.1", port, username="monitor", password="wrong") as client:
|
|
with pytest.raises(NutError) as excinfo:
|
|
await client.login()
|
|
finally:
|
|
await server.stop()
|
|
assert excinfo.value.code == "ACCESS-DENIED"
|
|
assert server.received[:1] == ["USERNAME monitor"]
|
|
|
|
|
|
async def test_upsd_rejection_carries_the_error_code() -> None:
|
|
server = FakeUpsd(require_auth=True)
|
|
port = await server.start()
|
|
try:
|
|
async with NutClient("127.0.0.1", port, username="nobody", password="pw") as client:
|
|
with pytest.raises(NutError) as excinfo:
|
|
await client.login()
|
|
assert excinfo.value.code == "ACCESS-DENIED"
|
|
assert "upsd.users" in str(excinfo.value) or "refused" in str(excinfo.value) or str(excinfo.value)
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
async def test_connection_refused_is_reported_as_unreachable() -> None:
|
|
"""A closed port must read as 'upsd is not answering', never as an empty UPS list."""
|
|
client = NutClient("127.0.0.1", 1, timeout=1.0)
|
|
with pytest.raises(NutError) as excinfo:
|
|
await client.connect()
|
|
assert excinfo.value.code == "UNREACHABLE"
|
|
# The reason is the sentence the operator actually reads (health record, alert, feed).
|
|
assert "ConnectionRefusedError" in str(excinfo.value)
|
|
|
|
|
|
async def test_connect_timeout_names_the_timeout_instead_of_an_empty_parenthetical(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""`wait_for()` raises a TimeoutError with no message of its own.
|
|
|
|
A firewalled port therefore used to print `… (TimeoutError: )`, which explains nothing.
|
|
This is the exact state the fleet is in while the laptop cannot reach upsd, so the
|
|
message has to carry the timeout it hit.
|
|
"""
|
|
|
|
async def never_connects(*_args: object, **_kwargs: object) -> None:
|
|
await asyncio.sleep(0.2)
|
|
|
|
monkeypatch.setattr(asyncio, "open_connection", never_connects)
|
|
client = NutClient("ups.example.net", 3051, timeout=0.01)
|
|
with pytest.raises(NutError) as excinfo:
|
|
await client.connect()
|
|
assert excinfo.value.code == "UNREACHABLE"
|
|
message = str(excinfo.value)
|
|
assert "timed out after 0.01s" in message
|
|
assert "TimeoutError" not in message
|
|
|
|
|
|
async def test_server_closing_early_is_reported() -> None:
|
|
server = FakeUpsd(close_early=True)
|
|
port = await server.start()
|
|
try:
|
|
async with NutClient("127.0.0.1", port, timeout=2.0) as client:
|
|
with pytest.raises(NutError) as excinfo:
|
|
await client.list_ups()
|
|
assert excinfo.value.code == "CLOSED"
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
async def test_a_silent_upsd_times_out_instead_of_hanging() -> None:
|
|
server = FakeUpsd(hang=True)
|
|
port = await server.start()
|
|
try:
|
|
async with NutClient("127.0.0.1", port, timeout=0.3) as client:
|
|
with pytest.raises(NutError) as excinfo:
|
|
await client._send("INSTCMD cyberpower beeper.mute")
|
|
await client._read_line()
|
|
assert excinfo.value.code == "TIMEOUT"
|
|
finally:
|
|
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:
|
|
values = dict(VAR_ROWS)
|
|
values.update(overrides)
|
|
return UpsReading(name="cyberpower", description="Cyber Power PR1500LCDRT2U",
|
|
vars=values, commands=list(CMDS))
|
|
|
|
|
|
def test_status_flags_and_state_chips() -> None:
|
|
ups = reading()
|
|
assert ups.status_flags == ["OL", "CHRG"]
|
|
labels = {state["label"] for state in ups.states()}
|
|
assert "online" in labels and "charging" in labels
|
|
assert all(state["severity"] in ("ok", "warn", "fail", "info") for state in ups.states())
|
|
|
|
|
|
def test_unknown_status_flag_is_shown_not_dropped() -> None:
|
|
ups = reading(**{"ups.status": "OL OFF"})
|
|
ids = {state["id"] for state in ups.states()}
|
|
assert "OFF" in ids, "an unrecognised-in-combination flag must still be shown, not swallowed"
|
|
|
|
|
|
def test_on_battery_is_derived_from_the_flag_not_from_charge() -> None:
|
|
assert reading().on_battery is False
|
|
assert reading(**{"ups.status": "OB"}).on_battery is True
|
|
|
|
|
|
def test_alarms_are_split_and_cleaned() -> None:
|
|
ups = reading(**{"ups.alarm": "BATT.VERIFYFAIL, OVERLOAD "})
|
|
assert ups.alarms == ["BATT.VERIFYFAIL", "OVERLOAD"]
|
|
|
|
|
|
def test_number_returns_none_for_absent_and_non_numeric() -> None:
|
|
ups = reading()
|
|
assert ups.number("battery.charge") == 100.0
|
|
assert ups.number("does.not.exist") is None
|
|
assert ups.number("ups.status") is None
|
|
|
|
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
(None, None), (27.4, 27.4), (300.4, 27.25), (150.0, 150.0), (151.0, -122.15),
|
|
])
|
|
def test_kelvin_is_detected_because_the_driver_cannot_be_trusted(raw, expected) -> None:
|
|
result = normalise_celsius(raw)
|
|
assert result == (None if expected is None else pytest.approx(expected, abs=0.01))
|
|
|
|
|
|
# ---- health assessment ---------------------------------------------------- #
|
|
|
|
def test_a_healthy_ups_reports_every_check_as_ok() -> None:
|
|
health = nut.assess(reading(**{"battery.temperature": "27.4", "battery.mfr.date": "2026-01-01",
|
|
"ups.status": "OL"}), now=1_767_225_600.0)
|
|
assert health["overall"] == "healthy"
|
|
assert health["counts"]["fail"] == 0 and health["counts"]["warn"] == 0
|
|
assert any(check["name"] == "Power path" for check in health["checks"])
|
|
|
|
|
|
def test_on_battery_with_little_runtime_is_a_failure() -> None:
|
|
health = nut.assess(reading(**{"ups.status": "OB", "battery.charge": "18",
|
|
"battery.runtime": "90",
|
|
"battery.temperature": "27.4"}), now=1_767_225_600.0)
|
|
assert health["overall"] == "at risk"
|
|
runtime = next(c for c in health["checks"] if c["name"] == "Runtime estimate")
|
|
assert runtime["status"] in ("warn", "fail")
|
|
|
|
|
|
def test_old_battery_is_warned_about_with_the_actual_age() -> None:
|
|
health = nut.assess(reading(**{"battery.temperature": "27.4", "battery.mfr.date": "2019-01-01"}),
|
|
now=1_767_225_600.0)
|
|
age = next(c for c in health["checks"] if c["name"] == "Battery age")
|
|
assert age["status"] == "warn"
|
|
assert "2019" in age["detail"] or "7" in age["detail"]
|
|
|
|
|
|
def test_a_kelvin_temperature_is_not_reported_as_a_melting_battery() -> None:
|
|
health = nut.assess(reading(), now=1_767_225_600.0) # 300.4 in the fixture
|
|
temp = next(c for c in health["checks"] if c["name"] == "Battery temperature")
|
|
assert temp["status"] == "ok"
|
|
assert "kelvin" in temp["detail"]
|
|
assert 20 < temp["value"] < 35
|
|
|
|
|
|
def test_a_hot_battery_fails() -> None:
|
|
health = nut.assess(reading(**{"battery.temperature": "52"}), now=1_767_225_600.0)
|
|
temp = next(c for c in health["checks"] if c["name"] == "Battery temperature")
|
|
assert temp["status"] == "fail"
|
|
|
|
|
|
def test_a_failed_self_test_is_surfaced() -> None:
|
|
health = nut.assess(reading(**{"battery.test.result": "FAILED", "battery.temperature": "27.4"}),
|
|
now=1_767_225_600.0)
|
|
check = next(c for c in health["checks"] if c["name"] == "Last self-test")
|
|
assert check["status"] in ("warn", "fail")
|
|
|
|
|
|
def test_missing_test_history_is_unknown_not_ok() -> None:
|
|
values = {key: value for key, value in VAR_ROWS.items()
|
|
if not key.startswith("battery.test")}
|
|
values["battery.temperature"] = "27.4"
|
|
health = nut.assess(nut.UpsReading(name="cyberpower", vars=values, commands=[]),
|
|
now=1_767_225_600.0)
|
|
check = next(c for c in health["checks"] if c["name"] == "Last self-test")
|
|
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)
|
|
assert health["overall"] in ("degraded", "at risk")
|
|
|
|
|
|
# ---- command gating ------------------------------------------------------- #
|
|
|
|
def base_commands() -> list[str]:
|
|
return list(CMDS)
|
|
|
|
|
|
def by_name(items: list[dict], command: str) -> dict:
|
|
return next(item for item in items if item["command"] == command)
|
|
|
|
|
|
def test_commands_are_disabled_when_actions_are_off() -> None:
|
|
items = nut.available_commands(base_commands(), allow_actions=False, read_only=False, has_credentials=True,
|
|
on_battery=False)
|
|
deep = by_name(items, "test.battery.start.deep")
|
|
assert deep["enabled"] is False
|
|
assert "NUT_ALLOW_ACTIONS" in deep["reason"]
|
|
|
|
|
|
def test_commands_are_disabled_without_credentials_that_have_actions() -> None:
|
|
items = nut.available_commands(base_commands(), allow_actions=True, read_only=False, has_credentials=False,
|
|
on_battery=False)
|
|
assert by_name(items, "beeper.mute")["enabled"] is False
|
|
assert "upsd.users" in by_name(items, "beeper.mute")["reason"]
|
|
|
|
|
|
def test_dangerous_commands_are_blocked_while_on_battery() -> None:
|
|
"""A deep test while already on battery can end the runtime early — that is the whole
|
|
thing the UPS is protecting against, so the UI must not offer it."""
|
|
items = nut.available_commands(base_commands(), allow_actions=True, read_only=False, has_credentials=True,
|
|
on_battery=True)
|
|
deep = by_name(items, "test.battery.start.deep")
|
|
assert deep["enabled"] is False
|
|
assert "battery" in deep["reason"].lower()
|
|
safe = by_name(items, "beeper.mute")
|
|
assert safe["enabled"] is True
|
|
|
|
|
|
def test_read_only_overrides_everything_else() -> None:
|
|
items = nut.available_commands(base_commands(), allow_actions=True, read_only=True,
|
|
has_credentials=True, on_battery=False)
|
|
assert items and all(item["enabled"] is False for item in items)
|
|
assert any("DASH_READ_ONLY" in (item["reason"] or "") for item in items)
|
|
|
|
|
|
def test_risk_levels_are_labelled_per_command() -> None:
|
|
items = nut.available_commands(base_commands(), allow_actions=True, read_only=False, has_credentials=True,
|
|
on_battery=False)
|
|
assert by_name(items, "beeper.mute")["risk"] == nut.RISK_SAFE
|
|
assert by_name(items, "test.battery.start.deep")["risk"] == nut.RISK_DANGEROUS
|
|
assert by_name(items, "load.off")["risk"] == nut.RISK_DANGEROUS
|
|
|
|
|
|
def test_commands_the_driver_does_not_advertise_are_never_offered() -> None:
|
|
items = nut.available_commands(["beeper.mute"], allow_actions=True, read_only=False, has_credentials=True,
|
|
on_battery=False)
|
|
assert [item["command"] for item in items] == ["beeper.mute"]
|
|
|
|
|
|
def test_commands_outside_the_risk_catalog_are_never_offered() -> None:
|
|
"""A driver can advertise anything; the dashboard only sends what it has a risk label for."""
|
|
items = nut.available_commands(["beeper.mute", "something.new"], allow_actions=True,
|
|
read_only=False, has_credentials=True, on_battery=False)
|
|
assert [item["command"] for item in items] == ["beeper.mute"]
|
|
|
|
|
|
def test_command_advice_catalog_is_complete_for_the_ui() -> None:
|
|
advice = nut.command_advice("test.battery.start.deep")
|
|
assert advice and advice["risk"] == nut.RISK_DANGEROUS
|
|
assert advice["label"] and advice["help"]
|
|
assert nut.command_advice("not.a.command") is None
|
|
|
|
|
|
# ---- payload & simulator -------------------------------------------------- #
|
|
|
|
def test_payload_carries_numbers_health_and_what_the_driver_offers() -> None:
|
|
payload = nut.payload(reading(), now=1_767_225_600.0)
|
|
assert payload["name"] == "cyberpower"
|
|
assert payload["identity"]["model"] == "PR1500LCDRT2U"
|
|
assert payload["numbers"]["charge_pct"] == 100.0
|
|
assert payload["numbers"]["battery_temperature"] == pytest.approx(27.25, abs=0.01)
|
|
assert payload["health"]["overall"] in ("healthy", "degraded", "at risk")
|
|
assert payload["advertised_commands"] == CMDS
|
|
assert payload["on_battery"] is False
|
|
# the raw dump keeps the original kelvin string: normalising for charts must not
|
|
# destroy the value the operator would compare against `upsc`
|
|
assert payload["extra"] or payload["status"] == "OL CHRG"
|
|
|
|
|
|
def test_payload_severity_colours_the_hero_battery_numbers() -> None:
|
|
"""charge / load / runtime carry the verdict so the page paints a class, not a threshold."""
|
|
payload = nut.payload(reading(), now=1_767_225_600.0) # charge 100, runtime 3720 s, load 34 %
|
|
assert payload["severity"] == {"charge_pct": "ok", "load_pct": "ok", "battery_runtime": "ok"}
|
|
# the verdict sits beside the numbers; it does not replace or rename any of them
|
|
assert payload["numbers"]["charge_pct"] == 100.0
|
|
assert payload["numbers"]["battery_runtime"] == 3720.0
|
|
|
|
|
|
@pytest.mark.parametrize("overrides,expected", [
|
|
({"battery.charge": "15"}, {"charge_pct": "fail"}),
|
|
({"battery.charge": "50"}, {"charge_pct": "warn"}), # reaching warn trips it
|
|
({"battery.charge": "50.1"}, {"charge_pct": "ok"}),
|
|
({"battery.runtime": "240"}, {"battery_runtime": "fail"}),
|
|
({"battery.runtime": "600"}, {"battery_runtime": "warn"}),
|
|
({"ups.load": "95"}, {"load_pct": "fail"}),
|
|
({"ups.load": "70"}, {"load_pct": "warn"}),
|
|
])
|
|
def test_severity_trips_the_ratified_ups_thresholds(overrides, expected) -> None:
|
|
payload = nut.payload(reading(**overrides), now=1_767_225_600.0)
|
|
for key, verdict in expected.items():
|
|
assert payload["severity"][key] == verdict, f"{key} with {overrides}"
|
|
|
|
|
|
def test_severity_is_null_when_the_driver_publishes_nothing() -> None:
|
|
"""Neither real UPS reports a battery temperature and either can drop `battery.runtime`;
|
|
an absent number gets no colour, and the health check keeps saying `unknown`."""
|
|
values = {key: value for key, value in VAR_ROWS.items()
|
|
if key not in ("battery.runtime", "battery.temperature")}
|
|
payload = nut.payload(nut.UpsReading(name="apc", vars=values, commands=[]),
|
|
now=1_767_225_600.0)
|
|
assert payload["numbers"]["battery_runtime"] is None
|
|
assert payload["severity"]["battery_runtime"] is None
|
|
assert payload["numbers"]["battery_temperature"] is None
|
|
temp = next(c for c in payload["health"]["checks"] if c["name"] == "Battery temperature")
|
|
assert temp["status"] == "unknown", "severity is additive: assess() keeps its own judgement"
|
|
|
|
|
|
def test_severity_does_not_touch_health_checks_or_numbers() -> None:
|
|
ups = reading()
|
|
payload = nut.payload(ups, now=1_767_225_600.0)
|
|
assert all(check["status"] in ("ok", "warn", "fail", "unknown")
|
|
for check in payload["health"]["checks"])
|
|
assert payload["health"] == nut.assess(ups, now=1_767_225_600.0), \
|
|
"the condition verdict and the value verdict must not contaminate each other"
|
|
|
|
|
|
async def test_simulated_ups_cards_carry_severity_too() -> None:
|
|
"""The UPS page is developable without a battery, and still gets real verdicts."""
|
|
for ups in await NutSimulator().read():
|
|
card = nut.payload(ups, now=1_767_225_600.0)
|
|
assert set(card["severity"]) == {"charge_pct", "load_pct", "battery_runtime"}
|
|
assert card["severity"]["charge_pct"] == "ok", "the sim starts at float charge"
|
|
|
|
|
|
async def test_a_fresh_simulator_reads_healthy_so_amber_still_means_something() -> None:
|
|
"""The dev model must not train people to ignore the UPS badge.
|
|
|
|
Its battery dates are fiction, so they are computed relative to today: a hard-coded
|
|
2019 manufacture date turns every simulated card amber forever, and an amber nobody
|
|
can clear is the same failure `assess()` avoids for a UPS that floats at 90 %.
|
|
"""
|
|
for ups in await NutSimulator().read():
|
|
card = nut.payload(ups)
|
|
assert card["health"]["overall"] == "healthy", card["health"]["checks"]
|
|
assert not [c for c in card["health"]["checks"] if c["status"] == "fail"]
|
|
|
|
|
|
def test_history_samples_only_include_real_numbers() -> None:
|
|
values = {key: value for key, value in VAR_ROWS.items() if key != "battery.runtime"}
|
|
samples = nut.history_samples(nut.UpsReading(name="cyberpower", vars=values, commands=[]))
|
|
assert "ups.charge_pct" in samples
|
|
assert "ups.battery_runtime_seconds" not in samples
|
|
assert all(isinstance(value, float) and value == value for value in samples.values())
|
|
|
|
|
|
def test_history_samples_normalise_the_temperature() -> None:
|
|
samples = nut.history_samples(reading())
|
|
assert samples["ups.battery_temperature_c"] == pytest.approx(27.25, abs=0.01)
|
|
|
|
|
|
async def test_simulator_reads_like_upsd_and_is_labelled() -> None:
|
|
readings = await NutSimulator().read()
|
|
assert readings, "the simulator must present at least one UPS"
|
|
for ups in readings:
|
|
assert "(simulated)" in ups.description, "simulated data must be identifiable"
|
|
assert ups.vars["battery.charge"]
|
|
assert "test.battery.start.deep" in ups.commands
|
|
|
|
|
|
async def test_simulator_commands_change_the_model_like_a_real_driver() -> None:
|
|
sim = NutSimulator()
|
|
name = (await sim.read())[0].name
|
|
assert "OK" in sim.command(name, "test.battery.start.quick")
|
|
after = (await sim.read())[0]
|
|
assert after.on_battery is True, "a quick test puts the load on battery"
|
|
assert "OK" in sim.command(name, "test.battery.stop")
|
|
assert (await sim.read())[0].on_battery is False
|
|
|
|
|
|
async def test_simulator_discharges_while_on_battery() -> None:
|
|
"""A simulated UPS that never drains cannot exercise a runtime or health path."""
|
|
sim = NutSimulator()
|
|
name = (await sim.read())[0].name
|
|
before = float((await sim.read())[0].vars["battery.charge"])
|
|
sim.command(name, "test.battery.start.deep")
|
|
ups = next(item for item in sim.ups if item.name == name)
|
|
ups.on_battery = True
|
|
for _ in range(30):
|
|
ups.step(60.0) # half an hour of model time on battery
|
|
assert ups.charge < before
|
|
|
|
|
|
def test_states_never_say_the_same_word_twice() -> None:
|
|
"""A driver exporting both the 2.8 token and the legacy flag gets one chip per fact.
|
|
|
|
Both spellings arrive at the word "charging"; a chip row that says it twice reads as
|
|
two events. The token wins (it distinguishes trim/boost/full), the subsumed flag goes.
|
|
"""
|
|
both = UpsReading(name="x", vars={"battery.charger.status": "chg", "ups.status": "OL CHRG"})
|
|
assert [s["label"] for s in both.states()] == ["charging", "online"]
|
|
dischrg = UpsReading(name="x", vars={"battery.charger.status": "dchg", "ups.status": "OB DISCHRG"})
|
|
assert [s["label"] for s in dischrg.states()] == ["discharging", "on battery"]
|
|
plain = UpsReading(name="x", vars={"ups.status": "OL CHRG"})
|
|
assert [s["label"] for s in plain.states()] == ["online", "charging"], \
|
|
"the flag-only route keeps both facts"
|
|
|
|
|
|
def test_a_recharging_simulated_ups_reports_charging_like_a_real_driver() -> None:
|
|
"""A below-float pack on mains is *charging* — APC prints exactly `OL CHRG` there.
|
|
|
|
The `/ups` glyph's bolt is keyed on `charger_status == "chg"`, and a model that only
|
|
ever answered `ol`/`OL` made that state unreachable in dev — the flag and the charger
|
|
token also contradicted each other, plain `OL` next to a charge that was climbing.
|
|
"""
|
|
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", charge=60.0)
|
|
vars_ = ups.vars(time.time())
|
|
assert vars_["ups.status"] == "OL CHRG"
|
|
assert vars_["battery.charger.status"] == "chg"
|
|
card = nut.payload(UpsReading(name="apc", vars=vars_, commands=[]))
|
|
assert card["charger_status"] == "chg", "the page derives the bolt from this token only"
|
|
assert card["on_battery"] is False, "OL CHRG is on line, not on the battery"
|
|
|
|
|
|
def test_a_discharging_simulated_ups_is_never_online() -> None:
|
|
"""`ol`/`full` next to `OB` is a contradiction; a drawn-from battery reports `dchg`."""
|
|
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", charge=100.0, on_battery=True)
|
|
vars_ = ups.vars(time.time())
|
|
assert "OL" not in vars_["ups.status"].split()
|
|
assert vars_["battery.charger.status"] == "dchg"
|
|
|
|
|
|
async def test_simulator_command_for_unknown_ups_raises() -> None:
|
|
sim = NutSimulator()
|
|
with pytest.raises(KeyError):
|
|
sim.command("nope", "beeper.mute")
|
|
|
|
|
|
def test_nut_simulate_is_the_only_switch_and_says_so() -> None:
|
|
"""The enable rule lives once, next to the model it swaps in.
|
|
|
|
`app.py` builds the monitor through this, so `1/true/yes/on` mean the same thing in the
|
|
assembly and in the module — and anything else means *real upsd*, which is the default that
|
|
must never be reachable by an empty or half-set variable.
|
|
"""
|
|
assert isinstance(from_env("1"), NutSimulator)
|
|
assert isinstance(from_env(" TRUE "), NutSimulator)
|
|
assert from_env(None) is None
|
|
assert from_env("") is None
|
|
assert from_env("0") is None, "a commented-out or zeroed NUT_SIMULATE must read real upsd"
|
|
|
|
|
|
async def test_every_command_the_simulator_advertises_is_implemented() -> None:
|
|
"""`commands()` is what the `/ups` page turns into buttons, so it must not list a lie.
|
|
|
|
A command the model advertises but `run_command()` does not implement raises, and the dev
|
|
page would then show `refused` for a button that is supposed to work — the simulator is
|
|
the only driver this app's one write path is ever exercised against, so an unimplemented
|
|
advertised command is a bug in the model, not a missing feature.
|
|
"""
|
|
sim = NutSimulator()
|
|
ups = (await sim.read())[0]
|
|
assert ups.commands, "a simulator with no commands cannot exercise the buttons at all"
|
|
for command in ups.commands:
|
|
assert sim.command(ups.name, command).startswith("OK"), command
|
|
|
|
|
|
def test_an_unimplemented_command_is_refused_by_the_model_too() -> None:
|
|
"""Silently accepting a command the driver would reject hides a bad catalog entry."""
|
|
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1")
|
|
with pytest.raises(ValueError):
|
|
ups.run_command("outlet.1.switch.off", now=time.time())
|
|
|
|
|
|
def test_the_beeper_commands_change_the_status_a_driver_would_publish() -> None:
|
|
"""`beeper.mute` answering OK while nothing changes teaches people to trust a dead button.
|
|
|
|
Real drivers report `ups.beeper.status`, and the NUT variable dump on the card shows it,
|
|
so each of the three commands has its own observable state here — `mute` and `disable` are
|
|
different words in NUT and stay different here.
|
|
"""
|
|
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1")
|
|
assert ups.vars(time.time())["ups.beeper.status"] == "enabled"
|
|
now = time.time()
|
|
ups.run_command("beeper.mute", now=now)
|
|
assert ups.vars(now)["ups.beeper.status"] == "muted"
|
|
ups.run_command("beeper.disable", now=now)
|
|
assert ups.vars(now)["ups.beeper.status"] == "disabled"
|
|
ups.run_command("beeper.enable", now=now)
|
|
assert ups.vars(now)["ups.beeper.status"] == "enabled"
|
|
|
|
|
|
def test_a_finished_simulated_test_reports_its_result_and_a_stopped_one_reports_nothing() -> None:
|
|
"""The command → state change → health-check path the simulator exists for.
|
|
|
|
`ups.test.result` is the variable the "Last self-test" check reads, so a quick test that
|
|
ends with the pack back on line but the old result still published shows a card that never
|
|
learned the test happened. An aborted test writes no result: a driver does not.
|
|
"""
|
|
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", test_result="FAILED",
|
|
test_date="2020-01-01")
|
|
now = time.time()
|
|
ups.run_command("test.battery.start.quick", now=now)
|
|
assert ups.on_battery is True
|
|
ups.tick(now + 20.0)
|
|
assert ups.on_battery is False, "a quick test is over after its ~12s window"
|
|
assert ups.test_result == "PASSED"
|
|
assert ups.test_date == time.strftime("%Y-%m-%d", time.gmtime(now))
|
|
|
|
aborted = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", test_result="FAILED")
|
|
aborted.run_command("test.battery.start.quick", now=now)
|
|
aborted.run_command("test.battery.stop", now=now)
|
|
aborted.tick(now + 20.0)
|
|
assert aborted.test_result == "FAILED", "an aborted test must not be reported as a pass"
|
|
|
|
|
|
def test_a_simulated_alarm_reaches_the_card_and_the_health_check() -> None:
|
|
"""The `alarm:` chip and the `Alarms` row are reachable in dev only through the model.
|
|
|
|
`assess()` fails that row for the codes that mean the pack or the load is in trouble, and
|
|
neither UPS on this fleet has ever raised one — so the alarm path is pinned here instead of
|
|
against a capture nobody can produce on demand.
|
|
"""
|
|
quiet = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1")
|
|
now = time.time()
|
|
assert "ups.alarm" not in quiet.vars(now), "a quiet UPS must not invent an alarm"
|
|
|
|
loud = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", _alarms=["LB"])
|
|
card = nut.payload(UpsReading(name="apc", vars=loud.vars(now), commands=[]))
|
|
assert card["alarms"] == ["LB"]
|
|
check = next(c for c in card["health"]["checks"] if c["name"] == "Alarms")
|
|
assert check["status"] == "fail", "a low-battery alarm is not a warning"
|