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
@@ -0,0 +1,55 @@
"""
Start/stop "the radio" without every caller knowing which recorder backend is
active (node-26#45). Every path that used to call op25_client.start/stop — C2
config pushes, the op25_restart command, the dashboard, SDR re-pinning — goes
through here.
With RECORDER_BACKEND=trunk-recorder, OP25 is never started: both open the
same RTL-SDR, and OP25 holding it would starve Trunk Recorder. start() becomes
a restart request to trunk-recorder/run.sh instead.
"""
import time
from typing import Optional
from app.internal import trunk_recorder as tr
from app.internal.logger import logger
from app.internal.op25_client import op25_client
def name() -> str:
return "Trunk Recorder" if tr.enabled() else "OP25"
async def start(wait: bool = True) -> bool:
return await start_with_reason(wait) is None
async def start_with_reason(wait: bool = True) -> Optional[str]:
"""None on success, else a human-readable reason.
For Trunk Recorder, `wait` blocks until run.sh reports TR restarted (up
to trunk_recorder.RESTART_WAIT_SECONDS), like op25_client.start returns
only once OP25 has started — callers hand the spare dongles out next.
"""
if not tr.enabled():
return await op25_client.start_with_reason()
if tr.trunk_recorder.config_error:
return tr.trunk_recorder.config_error
requested = time.time()
if not tr.trunk_recorder.request_restart():
return f"Couldn't write to {tr.config_dir()} to restart Trunk Recorder."
if not wait:
return None
err = await tr.trunk_recorder.wait_started_since(requested)
if err:
logger.error(err)
return err
async def stop() -> bool:
if not tr.enabled():
return await op25_client.stop()
ok = tr.trunk_recorder.request_stop()
if ok:
logger.info("Trunk Recorder stop requested.")
return ok