Files
node-26/drb-edge-node/app/internal/tr_calls.py
T
Logan CusanoandClaude Opus 5.5 e2a5b63067
CI / lint (push) Successful in 21s
CI / test-op25 (push) Successful in 28s
CI / test (push) Successful in 1m0s
edge-node: Trunk Recorder backend behind RECORDER_BACKEND (node-26#45)
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>
2026-10-28 00:21:48 -04:00

211 lines
8.6 KiB
Python

"""
Trunk Recorder call JSON -> what DRB sends for one call (node-26#45).
TR writes one JSON per call next to its WAV and passes both to uploadScript.
The shape is `create_call_json` in trunk-recorder/call_concluder/
call_concluder.cc at v5.2.1 (keys, in order: freq, freq_error, signal, noise,
source_num, recorder_num, tdma_slot, phase2_tdma, start_time, stop_time,
start_time_ms, stop_time_ms, emergency, priority, mode, duplex, encrypted,
call_length, call_length_ms, talkgroup, talkgroup_tag, talkgroup_description,
talkgroup_group_tag, talkgroup_group, color_code, audio_type, short_name,
patched_talkgroups?, freqList[], srcList[]). Older TR releases omit the *_ms
keys, so both are read.
Pure: trunk_recorder.py does the I/O.
"""
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
DRIVER = "trunk-recorder"
# Fixed namespace so a call's id is a pure function of its JSON: re-reading the
# same file after a crash yields the same call_id, so C2 overwrites rather than
# duplicates (trunk_recorder.py relies on this to be restart-safe).
CALL_ID_NAMESPACE = uuid.UUID("5d0c7f4e-2b8f-4e0e-9a59-45c0de7c0045")
def _iso(epoch: float) -> str:
return datetime.fromtimestamp(epoch, timezone.utc).isoformat()
def _int(value: Any) -> Optional[int]:
try:
return int(value)
except (TypeError, ValueError):
return None
def _epoch(data: Dict[str, Any], key: str) -> Optional[float]:
ms = data.get(f"{key}_ms")
if isinstance(ms, (int, float)) and ms > 0:
return ms / 1000.0
s = data.get(key)
if isinstance(s, (int, float)) and s > 0:
return float(s)
return None
@dataclass
class TrCall:
call_id: str
tgid: int
tgid_name: str
freq: Optional[int]
start_epoch: float
stop_epoch: float
units: List[int] = field(default_factory=list)
emergency: bool = False
encrypted: bool = False
src_list: List[Dict[str, Any]] = field(default_factory=list)
freq_list: List[Dict[str, Any]] = field(default_factory=list)
call_length: Optional[float] = None
signal: Optional[int] = None
noise: Optional[int] = None
freq_error: Optional[int] = None
decode_errors: Optional[int] = None
spikes: Optional[int] = None
patched_talkgroups: List[int] = field(default_factory=list)
short_name: str = ""
@property
def srcaddr(self) -> Optional[int]:
return self.units[0] if self.units else None
def start_payload(self) -> Dict[str, Any]:
"""MQTT call_start — the keys main.on_call_start publishes, which C2's
_on_call_start reads (tgid, tgid_name, freq, srcaddr, started_at)."""
return {
"call_id": self.call_id,
"tgid": self.tgid,
"tgid_name": self.tgid_name,
"freq": self.freq,
"srcaddr": self.srcaddr,
"started_at": _iso(self.start_epoch),
"started_at_epoch": self.start_epoch,
"attributed": True,
"driver": DRIVER,
}
def audio_end_epoch(self, wav_seconds: Optional[float] = None) -> float:
"""Wall clock at the END of the audio file.
NOT stop_time: TR joins a call's transmissions back to back and drops
the gaps between them, so the WAV is shorter than stop - start by the
sum of the gaps. start + WAV length is what the file's last sample
maps to only if there were no gaps; the exact per-transmission map is
srcList/freqList (time = wall clock, pos = offset in the file), sent
as src_list / freq_list.
"""
if wav_seconds is None:
return self.stop_epoch
return self.start_epoch + wav_seconds
def end_payload(self, wav_seconds: Optional[float] = None) -> Dict[str, Any]:
"""MQTT call_end. TR cut the call on decoder boundaries with no
trimming, so the audio starts at the call start; see audio_end_epoch
for why it does not end at the call end."""
audio_end = self.audio_end_epoch(wav_seconds)
out = self.start_payload()
out.update({
"ended_at": _iso(self.stop_epoch),
"ended_at_epoch": self.stop_epoch,
"audio_start_at": _iso(self.start_epoch),
"audio_end_at": _iso(audio_end),
"audio_start_epoch": self.start_epoch,
"audio_end_epoch": audio_end,
"units": self.units,
"emergency": self.emergency,
"encrypted": self.encrypted,
})
return out
def upload_extra(self) -> Dict[str, str]:
"""Extra /upload form fields. C2 declares none of these yet, so FastAPI
ignores them (same as audio_start_epoch today); they are here for when
it does."""
extra = {
"recorder_backend": DRIVER,
"units": ",".join(str(u) for u in self.units),
"src_list": json.dumps(self.src_list, separators=(",", ":")),
"freq_list": json.dumps(self.freq_list, separators=(",", ":")),
"emergency": "1" if self.emergency else "0",
"encrypted": "1" if self.encrypted else "0",
"started_at_epoch": f"{self.start_epoch:.3f}",
"ended_at_epoch": f"{self.stop_epoch:.3f}",
}
optional = {
"freq": self.freq, "call_length": self.call_length, "signal": self.signal, "noise": self.noise,
"freq_error": self.freq_error, "decode_errors": self.decode_errors, "spikes": self.spikes,
}
extra.update({k: str(v) for k, v in optional.items() if v is not None})
if self.patched_talkgroups:
extra["patched_talkgroups"] = ",".join(str(t) for t in self.patched_talkgroups)
return extra
def parse_call(data: Dict[str, Any], node_id: str, file_stem: str = "") -> TrCall:
"""One TR call JSON -> TrCall. ValueError if it has no talkgroup or start."""
tgid = _int(data.get("talkgroup"))
if not tgid or tgid <= 0:
raise ValueError(f"call JSON has no talkgroup ({data.get('talkgroup')!r})")
start = _epoch(data, "start_time")
if start is None:
raise ValueError("call JSON has no start_time")
stop = _epoch(data, "stop_time")
length = data.get("call_length_ms")
length_s = (length / 1000.0) if isinstance(length, (int, float)) and length > 0 else None
if length_s is None and isinstance(data.get("call_length"), (int, float)):
length_s = float(data["call_length"])
if stop is None or stop < start:
stop = start + (length_s or 0.0)
src_list: List[Dict[str, Any]] = []
units: List[int] = []
unit_emergency = False
for s in data.get("srcList") or []:
if not isinstance(s, dict):
continue
src = _int(s.get("src"))
entry = {"src": src, "time": s.get("time"), "pos": s.get("pos"),
"emergency": bool(_int(s.get("emergency")))}
if s.get("tag"):
entry["tag"] = s.get("tag")
src_list.append(entry)
unit_emergency = unit_emergency or entry["emergency"]
# TR reports 0 / -1 when the unit id wasn't decoded.
if src and src > 0 and src not in units:
units.append(src)
freq_list = [f for f in data.get("freqList") or [] if isinstance(f, dict)]
errors = sum(_int(f.get("error_count")) or 0 for f in freq_list) if freq_list else None
spikes = sum(_int(f.get("spike_count")) or 0 for f in freq_list) if freq_list else None
freq = _int(data.get("freq"))
short_name = str(data.get("short_name") or "")
key = f"{node_id}|{short_name}|{tgid}|{start:.3f}|{freq}|{file_stem}"
return TrCall(
call_id=str(uuid.uuid5(CALL_ID_NAMESPACE, key)),
tgid=tgid,
tgid_name=str(data.get("talkgroup_tag") or data.get("talkgroup_description") or ""),
freq=freq,
start_epoch=start,
stop_epoch=stop,
units=units,
emergency=bool(_int(data.get("emergency"))) or unit_emergency,
encrypted=bool(_int(data.get("encrypted"))),
src_list=src_list,
freq_list=[{"freq": _int(f.get("freq")), "time": f.get("time"), "pos": f.get("pos"), "len": f.get("len"),
"error_count": _int(f.get("error_count")), "spike_count": _int(f.get("spike_count"))}
for f in freq_list],
call_length=round(length_s, 3) if length_s is not None else None,
signal=_int(data.get("signal")),
noise=_int(data.get("noise")),
freq_error=_int(data.get("freq_error")),
decode_errors=errors,
spikes=spikes,
patched_talkgroups=[t for t in (_int(x) for x in data.get("patched_talkgroups") or []) if t],
short_name=short_name,
)