edge-node: Trunk Recorder backend behind RECORDER_BACKEND (node-26#45)
CI / lint (push) Successful in 21s
CI / test-op25 (push) Successful in 28s
CI / test (push) Successful in 1m0s

RECORDER_BACKEND=trunk-recorder (default op25, unchanged):
- tr_config: TR config.json + talkgroup/channel CSVs from the C2 system
  config (P25 trunked, P25 conventional, analog with squelch).
- tr_calls: TR call JSON -> MQTT call_start/call_end + /upload fields
  (talkgroup, start/stop, freq, units from srcList, emergency, encrypted).
- trunk_recorder: inbox ingest through the #44 spool with deterministic
  call_ids, supervisor requests, live audio via simplestream -> pacat ->
  drb_sink for Discord (one talkgroup at a time).
- radio_backend: start/stop dispatch so OP25 never grabs the SDR, and the
  OP25 watcher/PulseAudio recorder stay off (no double uploads).
- health/dashboard/checkin show the active backend and TR health.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-28 00:21:48 -04:00
co-authored by Claude Opus 5.5
parent c9f162a508
commit e2a5b63067
12 changed files with 1849 additions and 28 deletions
+29 -8
View File
@@ -97,9 +97,18 @@ async def get_status():
"icecast_port": settings.icecast_port,
"icecast_mount": settings.icecast_mount,
"op25": op25_status,
# Which recorder is live (node-26#45) and, for Trunk Recorder, its
# supervisor state / decode rate / handed-over calls.
"recorder_backend": settings.recorder_backend,
"trunk_recorder": _tr_facts(),
}
def _tr_facts():
from app.internal import trunk_recorder as tr
return tr.trunk_recorder.health_facts() if tr.enabled() else None
@router.get("/health")
async def get_health():
"""Plain-language setup/status checklist for the dashboard (see internal/health.py)."""
@@ -110,7 +119,9 @@ async def get_health():
@router.post("/op25/start")
async def start_op25():
err = await op25_client.start_with_reason()
# The active recorder (node-26#45) — the dashboard's Start button.
from app.internal import radio_backend
err = await radio_backend.start_with_reason()
if err:
raise HTTPException(502, err)
return {"ok": True}
@@ -118,14 +129,18 @@ async def start_op25():
@router.post("/op25/stop")
async def stop_op25():
ok = await op25_client.stop()
from app.internal import radio_backend
ok = await radio_backend.stop()
if not ok:
raise HTTPException(502, "The op25 container did not answer the stop request.")
raise HTTPException(502, f"{radio_backend.name()} did not accept the stop request.")
return {"ok": True}
@router.get("/op25/log")
async def op25_log(lines: int = 80):
from app.internal import trunk_recorder as tr
if tr.enabled():
return {"lines": tr.trunk_recorder.log_tail(lines)}
out = await op25_client.log(lines)
if out is None:
raise HTTPException(502, "The op25 container isn't answering.")
@@ -138,17 +153,22 @@ async def get_config():
async def _restart_op25_with(config: SystemConfig) -> None:
"""Generate OP25's config for `config` and restart OP25 on it."""
"""Generate the active recorder's config for `config` and restart it on it."""
from app.main import _generate_op25_config
from app.internal import radio_backend
if not await _generate_op25_config(config):
from app.internal import trunk_recorder as tr
if tr.enabled():
detail = tr.trunk_recorder.config_error or f"Trunk Recorder config for {config.name} failed."
raise HTTPException(502, detail)
raise HTTPException(
502, f"The op25 container did not accept the config for {config.name} — is it running?",
)
await op25_client.stop()
await radio_backend.stop()
await asyncio.sleep(2)
err = await op25_client.start_with_reason()
err = await radio_backend.start_with_reason()
if err:
raise HTTPException(502, f"Config saved, but OP25 did not start: {err}")
raise HTTPException(502, f"Config saved, but {radio_backend.name()} did not start: {err}")
async def _apply_override(config: SystemConfig) -> None:
@@ -350,7 +370,8 @@ async def revert_config():
else:
# Nothing from C2 to go back to: stop rather than keep decoding a
# system the dashboard now says is cleared.
await op25_client.stop()
from app.internal import radio_backend
await radio_backend.stop()
if mqtt_manager.is_connected:
await mqtt_manager.publish_checkin()