Local dashboard: plain-language status checklist and C2-free listening
- /api/health: ordered checklist (password, C2 approval, MQTT, SDR, source,
OP25, decoding, audio, Icecast) with what's wrong and what to do; pure
build_checks() so every state (incl. Toughny's unapproved node) is tested
- /api/listen: pick P25 control channels / one analog freq / a cached C2
system, validated in MHz, stored as a local override; OP25 restarted with
the start failure reason surfaced
- /api/config/system no longer writes a raw blob OP25 can't read
- OP25 resumes after reboot on a locally chosen system, not only C2-assigned
- op25-container: analog (NBFM) config generation fixed (AttributeError +
wrong field names), /op25/log tail, start refuses without a config,
status detects a dead multi_rx ("crashed") -- node-26#12
- index.html rebuilt on UI_REDESIGN tokens; scanner drops DMR, shows System key
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6bf2c9dd93
commit
10f7db0a2c
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Local dashboard: the health checklist (/api/health) and choosing what to
|
||||
listen to without C2 (/api/listen, /api/config/system, /api/config/revert).
|
||||
|
||||
Same standalone-app pattern as test_auth_endpoints.py — the real routers,
|
||||
none of app.main's lifespan. OP25/MQTT are mocked at the client boundary.
|
||||
"""
|
||||
import base64
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import config_manager, credentials, health
|
||||
from app.internal.op25_client import parse_terminal_messages
|
||||
from app.routers import api, ui
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(credentials, "_CREDS_FILE", tmp_path / "credentials.json")
|
||||
monkeypatch.setattr(credentials, "_api_key", None)
|
||||
monkeypatch.setattr(credentials, "_auth_salt", None)
|
||||
monkeypatch.setattr(credentials, "_session_secret", None)
|
||||
monkeypatch.setattr(config_manager, "_CONFIG_FILE", tmp_path / "node_config.json")
|
||||
monkeypatch.setattr(settings, "dashboard_username", "tester")
|
||||
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
app.include_router(ui.router)
|
||||
auth = base64.b64encode(b"tester:s3cret-pass").decode()
|
||||
with TestClient(app, headers={"Authorization": f"Basic {auth}"}) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def op25():
|
||||
"""Mock the OP25 side of an apply: generate, stop, start, checkin."""
|
||||
with patch("app.main._generate_op25_config", AsyncMock(return_value=True)) as gen, \
|
||||
patch("app.routers.api.op25_client.stop", AsyncMock(return_value=True)) as stop, \
|
||||
patch("app.routers.api.op25_client.start_with_reason", AsyncMock(return_value=None)) as start, \
|
||||
patch("app.routers.api.asyncio.sleep", AsyncMock()), \
|
||||
patch("app.routers.api.mqtt_manager.publish_checkin", AsyncMock()) as checkin:
|
||||
yield {"gen": gen, "stop": stop, "start": start, "checkin": checkin}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health checklist — pure function of facts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _facts(**over):
|
||||
now = 1_000_000.0
|
||||
f = {
|
||||
"now": now, "node_id": "toughny", "default_password": False,
|
||||
"c2_url": "https://api.example", "has_api_key": True, "mqtt_connected": True,
|
||||
"mqtt_broker": "mqtt.example", "mqtt_port": 8883, "sdr_count": 1,
|
||||
"source": {"name": "County", "type": "P25", "system_id": "s1", "local": False},
|
||||
"op25_status": "running",
|
||||
"console": {"last_poll_epoch": now - 1, "channels": [], "trunk": [{"last_tsbk": now - 1, "rxchan": 851012500}],
|
||||
"last_heard": None},
|
||||
"audio_capturing": True, "last_voice_epoch": now - 30, "icecast_live": True, "icecast_port": 8000,
|
||||
}
|
||||
f.update(over)
|
||||
return f
|
||||
|
||||
|
||||
def _by_id(checks):
|
||||
return {c["id"]: c for c in checks}
|
||||
|
||||
|
||||
def test_healthy_node_is_all_ok():
|
||||
checks = health.build_checks(_facts())
|
||||
assert all(c["state"] == "ok" for c in checks), [c for c in checks if c["state"] != "ok"]
|
||||
assert "851.0125 MHz" in _by_id(checks)["decode"]["summary"]
|
||||
|
||||
|
||||
def test_unapproved_node_explains_wait_and_mqtt_refusal():
|
||||
"""Toughny's state: C2 set, no API key, broker refuses, nothing chosen."""
|
||||
c = _by_id(health.build_checks(_facts(has_api_key=False, mqtt_connected=False, source=None,
|
||||
op25_status="stopped")))
|
||||
assert c["c2"]["state"] == "warn" and "approve" in c["c2"]["action"]
|
||||
assert "toughny" in c["c2"]["action"]
|
||||
assert c["mqtt"]["state"] == "warn"
|
||||
assert c["source"]["state"] == "warn" and "Listen locally" in c["source"]["action"]
|
||||
assert c["op25"]["state"] == "off"
|
||||
assert c["decode"]["state"] == "off"
|
||||
|
||||
|
||||
def test_key_on_disk_but_mqtt_down_says_restart():
|
||||
c = _by_id(health.build_checks(_facts(mqtt_connected=False)))
|
||||
assert c["mqtt"]["state"] == "error"
|
||||
assert "restart the edge-node container" in c["mqtt"]["action"]
|
||||
|
||||
|
||||
def test_standalone_node_is_not_an_error():
|
||||
c = _by_id(health.build_checks(_facts(c2_url=None, has_api_key=False, mqtt_connected=False)))
|
||||
assert c["c2"]["state"] == "off"
|
||||
assert c["mqtt"]["state"] == "off"
|
||||
|
||||
|
||||
def test_no_sdr_and_crashed_op25():
|
||||
c = _by_id(health.build_checks(_facts(sdr_count=0, op25_status="crashed")))
|
||||
assert c["sdr"]["state"] == "error"
|
||||
assert c["op25"]["state"] == "error" and "log" in c["op25"]["action"]
|
||||
|
||||
|
||||
def test_running_but_no_control_channel():
|
||||
now = 1_000_000.0
|
||||
c = _by_id(health.build_checks(_facts(console={"last_poll_epoch": now, "channels": [],
|
||||
"trunk": [{"last_tsbk": 0}], "last_heard": None})))
|
||||
assert c["decode"]["state"] == "error"
|
||||
|
||||
|
||||
def test_analog_needs_no_control_channel():
|
||||
now = 1_000_000.0
|
||||
c = _by_id(health.build_checks(_facts(
|
||||
source={"name": "Fire", "type": "NBFM", "system_id": "local-analog", "local": True},
|
||||
console={"last_poll_epoch": now, "channels": [{"freq": 154430000}], "trunk": [], "last_heard": None},
|
||||
)))
|
||||
assert c["decode"]["state"] == "ok" and "154.4300 MHz" in c["decode"]["summary"]
|
||||
assert "this dashboard" in c["source"]["summary"]
|
||||
|
||||
|
||||
def test_op25_unreachable_and_icecast_down():
|
||||
c = _by_id(health.build_checks(_facts(op25_status=None, icecast_live=None)))
|
||||
assert c["op25"]["state"] == "error"
|
||||
assert c["stream"]["state"] == "error"
|
||||
|
||||
|
||||
def test_default_password_warns():
|
||||
assert _by_id(health.build_checks(_facts(default_password=True)))["password"]["state"] == "warn"
|
||||
|
||||
|
||||
def test_trunk_update_is_parsed():
|
||||
update = parse_terminal_messages([
|
||||
{"json_type": "trunk_update", "0": {"last_tsbk": 5.0, "rxchan": 851012500}, "nac": 0},
|
||||
{"json_type": "channel_update", "channels": ["0"], "0": {"freq": 1, "tgid": None}},
|
||||
])
|
||||
assert update.trunk == [{"last_tsbk": 5.0, "rxchan": 851012500}]
|
||||
assert len(update.channels) == 1
|
||||
|
||||
|
||||
def test_health_route_returns_checks(client):
|
||||
facts = _facts(now=time.time())
|
||||
with patch("app.internal.health.gather_facts", AsyncMock(return_value=facts)):
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert [c["id"] for c in r.json()["checks"]][:3] == ["password", "c2", "mqtt"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Choosing what to listen to
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_listen_p25_saves_override_and_restarts_op25(client, op25):
|
||||
r = client.post("/api/listen", json={
|
||||
"mode": "p25", "frequencies": [851.0125, 851.5125], "talkgroups": [{"id": 1001, "name": "Fire"}],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = config_manager.load_node_config()
|
||||
assert cfg.override_config.type == "P25"
|
||||
assert cfg.override_config.config["control_channels"] == [851.0125, 851.5125]
|
||||
assert cfg.override_config.config["talkgroups"] == [{"id": 1001, "name": "Fire"}]
|
||||
op25["gen"].assert_awaited_once()
|
||||
op25["start"].assert_awaited_once()
|
||||
op25["checkin"].assert_not_awaited() # MQTT not connected in tests
|
||||
|
||||
|
||||
def test_listen_analog_single_frequency(client, op25):
|
||||
r = client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43], "name": "Fire"})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = config_manager.load_node_config().override_config
|
||||
assert (cfg.type, cfg.name, cfg.config["control_channels"]) == ("NBFM", "Fire", [154.43])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,needle", [
|
||||
({"mode": "p25", "frequencies": []}, "at least one"),
|
||||
({"mode": "p25", "frequencies": [851012500]}, "MHz"),
|
||||
({"mode": "analog", "frequencies": [154.43, 155.1]}, "exactly one"),
|
||||
({"mode": "system", "system_id": "nope"}, "saved list"),
|
||||
])
|
||||
def test_listen_rejects_bad_input_with_a_readable_reason(client, op25, body, needle):
|
||||
r = client.post("/api/listen", json=body)
|
||||
assert r.status_code in (400, 404)
|
||||
assert needle in r.json()["detail"]
|
||||
op25["gen"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_listen_reports_op25_start_failure(client, op25):
|
||||
op25["start"].return_value = "No OP25 config yet — choose a system or frequency first."
|
||||
r = client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43]})
|
||||
assert r.status_code == 502
|
||||
assert "did not start" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_dmr_is_refused_up_front(client, op25):
|
||||
r = client.post("/api/config/override", json={"system_config": {
|
||||
"system_id": "x", "name": "x", "type": "DMR", "config": {"control_channels": [450.1]},
|
||||
}})
|
||||
assert r.status_code == 400 and "DMR" in r.json()["detail"]
|
||||
op25["gen"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_config_system_goes_through_op25_generation(client, op25, tmp_path, monkeypatch):
|
||||
"""It used to dump the raw blob into active.cfg.json — not an OP25 config."""
|
||||
monkeypatch.setattr(settings, "config_path", str(tmp_path))
|
||||
r = client.post("/api/config/system", json={
|
||||
"system_id": "s1", "name": "County", "type": "P25", "config": {"control_channels": [851.0125]},
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
op25["gen"].assert_awaited_once()
|
||||
assert not (tmp_path / "active.cfg.json").exists()
|
||||
cfg = config_manager.load_node_config()
|
||||
assert cfg.configured and cfg.system_config.name == "County"
|
||||
|
||||
|
||||
def test_revert_without_c2_system_stops_op25(client, op25):
|
||||
client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43]})
|
||||
op25["stop"].reset_mock()
|
||||
r = client.post("/api/config/revert")
|
||||
assert r.status_code == 200
|
||||
assert config_manager.load_node_config().override_config is None
|
||||
op25["stop"].assert_awaited_once()
|
||||
|
||||
|
||||
def test_status_exposes_stream_parts_and_source(client, op25):
|
||||
client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43]})
|
||||
with patch("app.routers.api.op25_client.status", AsyncMock(return_value={"status": "running"})):
|
||||
d = client.get("/api/status").json()
|
||||
assert d["icecast_port"] == settings.icecast_port and d["icecast_mount"] == settings.icecast_mount
|
||||
assert d["local_choice"] is True and d["has_c2_system"] is False
|
||||
assert d["system_type"] == "NBFM"
|
||||
|
||||
|
||||
def test_dashboard_page_has_no_dead_api_instruction(client):
|
||||
client.post("/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False)
|
||||
html = client.get("/").text
|
||||
assert "POST /api/config/system" not in html
|
||||
assert "/api/listen" in html and "/api/health" in html
|
||||
Reference in New Issue
Block a user