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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c9f162a508
commit
e2a5b63067
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Trunk Recorder config.json (+ CSVs) from the C2 system config (node-26#45).
|
||||
|
||||
Same input as radio_config.build_generate_payload — the SystemConfig C2 pushes —
|
||||
so a node can switch backends without anyone re-entering its system:
|
||||
|
||||
P25 trunked -> system type "p25", control_channels, talkgroupsFile
|
||||
P25 conventional -> "conventionalP25", channelFile
|
||||
Analog -> "conventional", channelFile, squelch
|
||||
|
||||
Schema per Trunk Recorder v5.2.1 docs/CONFIGURE.md. Pure functions: the I/O is
|
||||
in trunk_recorder.py. Unverified against a radio — see the notes inline.
|
||||
|
||||
Conventional channels are numbered with conventional.channel_tgid (frequency in
|
||||
100 Hz units), the same synthetic talkgroup the OP25 path reports, so C2 names
|
||||
a TR call and an OP25 call on the same channel identically.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app.internal.conventional import channel_tgid
|
||||
from app.internal.radio_config import (
|
||||
ANALOG, CONVENTIONAL, DMR, TRUNKED, analog_frequency, conventional_channels, system_mode, to_hz,
|
||||
)
|
||||
|
||||
# Paths INSIDE the trunk-recorder container (see docker-compose.yml).
|
||||
TR_APP_DIR = "/app" # tr_config_dir is mounted here
|
||||
TR_UPLOAD_SCRIPT = "/drb/drb-upload.sh" # ./trunk-recorder, read-only
|
||||
TR_CAPTURE_DIR = "/recordings/tr-capture" # ./recordings
|
||||
TR_TEMP_DIR = "/dev/shm" # TR's recommended tmpfs (shm_size in compose)
|
||||
TALKGROUPS_FILE = "talkgroups.csv"
|
||||
CHANNELS_FILE = "channels.csv"
|
||||
SHORT_NAME = "drb"
|
||||
|
||||
# TR splits a call longer than this; mirrors call_recorder.MAX_RECORDING_SECONDS.
|
||||
MAX_DURATION_SECONDS = 600
|
||||
# Seconds of control-channel silence before TR closes a call (TR's default).
|
||||
CALL_TIMEOUT_SECONDS = 3
|
||||
|
||||
# Of an RTL-SDR's sample rate, the share that is usable passband (anti-alias
|
||||
# roll-off eats the edges). Channels must sit inside +-(rate * share / 2).
|
||||
USABLE_BANDWIDTH_SHARE = 0.8
|
||||
# The RTL-SDR's DC spike sits on the centre frequency; keep channels off it.
|
||||
DC_SPIKE_GUARD_HZ = 25_000
|
||||
CENTER_NUDGES_HZ = (0, 150_000, -150_000, 300_000, -300_000)
|
||||
|
||||
|
||||
def _fits(center: int, freqs: List[int], half_band: float) -> bool:
|
||||
return all(abs(f - center) <= half_band for f in freqs)
|
||||
|
||||
|
||||
def choose_center(freqs: List[int], rate: int, override: Optional[int] = None) -> int:
|
||||
"""A centre frequency that puts every channel in the passband and none on
|
||||
the DC spike. ValueError if they cannot all fit on one SDR."""
|
||||
if not freqs:
|
||||
raise ValueError("No frequencies to tune to.")
|
||||
half = rate * USABLE_BANDWIDTH_SHARE / 2
|
||||
if override:
|
||||
if not _fits(override, freqs, half):
|
||||
raise ValueError(f"TR_CENTER_HZ {override} leaves a channel outside the SDR's {2 * half / 1e6:.1f} MHz.")
|
||||
return int(override)
|
||||
lo, hi = min(freqs), max(freqs)
|
||||
if hi - lo > 2 * half:
|
||||
raise ValueError(
|
||||
f"Channels span {(hi - lo) / 1e6:.3f} MHz, more than one SDR at {rate / 1e6:.1f} Msps covers "
|
||||
f"({2 * half / 1e6:.2f} MHz). Trim the channel list or raise TR_SAMPLE_RATE."
|
||||
)
|
||||
mid = int(round((lo + hi) / 2))
|
||||
for nudge in CENTER_NUDGES_HZ:
|
||||
c = mid + nudge
|
||||
if _fits(c, freqs, half) and all(abs(f - c) >= DC_SPIKE_GUARD_HZ for f in freqs):
|
||||
return c
|
||||
return mid # channels packed around the middle: accept the spike over not fitting
|
||||
|
||||
|
||||
def _csv(header: List[str], rows: List[List[Any]]) -> str:
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, lineterminator="\n")
|
||||
w.writerow(header)
|
||||
w.writerows(rows)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def talkgroups_csv(talkgroups: List[Dict[str, Any]]) -> str:
|
||||
"""TR talkgroupsFile: header row required, "Decimal" first (CONFIGURE.md)."""
|
||||
rows = []
|
||||
for tg in talkgroups:
|
||||
if tg.get("id") is None:
|
||||
continue
|
||||
name = str(tg.get("name") or "").strip()
|
||||
rows.append([int(tg["id"]), "D", name, name, "", ""])
|
||||
return _csv(["Decimal", "Mode", "Alpha Tag", "Description", "Tag", "Category"], rows)
|
||||
|
||||
|
||||
def channels_csv(channels: List[Dict[str, Any]], squelch: Optional[float] = None) -> str:
|
||||
"""TR channelFile: header required, "TG Number" first; Frequency in Hz
|
||||
(no decimal point = Hz). Per-channel Squelch overrides the system's."""
|
||||
header = ["TG Number", "Frequency", "Alpha Tag", "Description"]
|
||||
if squelch is not None:
|
||||
header.append("Squelch")
|
||||
rows = []
|
||||
for ch in channels:
|
||||
name = ch.get("name") or f"{ch['frequency'] / 1e6:.4f} MHz"
|
||||
row = [channel_tgid(ch["frequency"]), ch["frequency"], name, name]
|
||||
if squelch is not None:
|
||||
row.append(squelch)
|
||||
rows.append(row)
|
||||
return _csv(header, rows)
|
||||
|
||||
|
||||
def _analog_channels(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Every analog channel the config lists (OP25 only ever used the first)."""
|
||||
out = []
|
||||
for raw in (config or {}).get("channels") or []:
|
||||
if isinstance(raw, dict) and raw.get("freq") not in (None, ""):
|
||||
out.append({"frequency": to_hz(raw["freq"]), "name": str(raw.get("name") or "").strip()})
|
||||
if not out:
|
||||
freq = analog_frequency(config)
|
||||
if freq:
|
||||
out.append({"frequency": freq, "name": ""})
|
||||
return out
|
||||
|
||||
|
||||
def _analog_squelch(config: Dict[str, Any], default_db: float) -> float:
|
||||
"""TR's squelch is an absolute power level — OP25's "power" mode. Its
|
||||
squelch_threshold carries over; noise/voice squelch_db has no TR
|
||||
equivalent, so those fall back to the default (which needs site tuning)."""
|
||||
value = (config or {}).get("squelch_threshold")
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return float(default_db)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"squelch_threshold {value!r} isn't a number.")
|
||||
|
||||
|
||||
def build_tr_config(
|
||||
system_type: str,
|
||||
name: str,
|
||||
config: Dict[str, Any],
|
||||
*,
|
||||
device_serial: Optional[str],
|
||||
ppm: Optional[float],
|
||||
sample_rate: int,
|
||||
gain: float,
|
||||
digital_recorders: int,
|
||||
min_duration: float,
|
||||
squelch_db: float,
|
||||
live_port: int,
|
||||
center_override: Optional[int] = None,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, str]]:
|
||||
"""SystemConfig fields -> (config.json dict, {filename: contents} to write beside it).
|
||||
|
||||
ValueError when the system can't be expressed (DMR, no channels, too wide).
|
||||
"""
|
||||
config = config or {}
|
||||
mode = system_mode(system_type, config)
|
||||
files: Dict[str, str] = {}
|
||||
# Kept per system, not globally: audioArchive/callLog false makes TR delete
|
||||
# the wav/json after uploadScript returns, which is why drb-upload.sh
|
||||
# copies them out before it exits.
|
||||
system: Dict[str, Any] = {
|
||||
"shortName": SHORT_NAME,
|
||||
"uploadScript": TR_UPLOAD_SCRIPT,
|
||||
"audioArchive": False,
|
||||
"callLog": False,
|
||||
# WAV, not TR's 32k AAC: the recorder's own rule is one lossless copy
|
||||
# for Whisper (call_recorder AUDIO_* notes). 8 kHz mono is ~1 MB/min.
|
||||
"compressWav": False,
|
||||
"minDuration": float(min_duration),
|
||||
"maxDuration": MAX_DURATION_SECONDS,
|
||||
"hideEncrypted": False,
|
||||
}
|
||||
source: Dict[str, Any] = {
|
||||
"driver": "osmosdr",
|
||||
# By serial, like OP25 (sdr_settings.pin_op25_device): "rtl=0" means
|
||||
# whichever dongle enumerated first, which a decoder can take.
|
||||
"device": f"rtl={device_serial}" if device_serial else "rtl=0",
|
||||
"rate": int(sample_rate),
|
||||
"gain": float(gain),
|
||||
"ppm": float(ppm or 0.0),
|
||||
}
|
||||
|
||||
if mode == DMR:
|
||||
raise ValueError("DMR isn't supported by the edge node.")
|
||||
|
||||
if mode == TRUNKED:
|
||||
ccs = [to_hz(c) for c in config.get("control_channels") or []]
|
||||
if not ccs:
|
||||
raise ValueError(f"System {name!r} has no control channels.")
|
||||
talkgroups = [tg for tg in config.get("talkgroups") or [] if tg.get("id") is not None]
|
||||
system.update({
|
||||
"type": "p25",
|
||||
"control_channels": ccs,
|
||||
# TR's default; "fsk4" is accepted too. Overridable from C2.
|
||||
"modulation": str(config.get("modulation") or "qpsk"),
|
||||
})
|
||||
# Same rule as OP25's whitelist: a talkgroup list means record only
|
||||
# those; no list means record everything.
|
||||
if talkgroups:
|
||||
files[TALKGROUPS_FILE] = talkgroups_csv(talkgroups)
|
||||
system["talkgroupsFile"] = f"{TR_APP_DIR}/{TALKGROUPS_FILE}"
|
||||
system["recordUnknown"] = False
|
||||
else:
|
||||
system["recordUnknown"] = True
|
||||
# Voice channels are wherever the system grants them; only the control
|
||||
# channels are known here, so the SDR is centred on those. A system
|
||||
# whose voice channels sit outside that ~2 MHz loses those calls (TR
|
||||
# logs "no source covering" for each) — needs a second source.
|
||||
freqs = ccs
|
||||
source["digitalRecorders"] = int(digital_recorders)
|
||||
elif mode == CONVENTIONAL:
|
||||
chans = conventional_channels(config)
|
||||
if not chans:
|
||||
raise ValueError(f"System {name!r} has no conventional channels.")
|
||||
# No NAC filter: TR's conventionalP25 records whatever it decodes, so a
|
||||
# mistyped NAC can no longer silently drop every call (node-26#33).
|
||||
system.update({"type": "conventionalP25", "channelFile": f"{TR_APP_DIR}/{CHANNELS_FILE}",
|
||||
"squelch": float(squelch_db)})
|
||||
files[CHANNELS_FILE] = channels_csv(chans)
|
||||
freqs = [c["frequency"] for c in chans]
|
||||
else: # ANALOG
|
||||
chans = _analog_channels(config)
|
||||
if not chans:
|
||||
raise ValueError(f"System {name!r} has no analog frequency.")
|
||||
squelch = _analog_squelch(config, squelch_db)
|
||||
system.update({"type": "conventional", "channelFile": f"{TR_APP_DIR}/{CHANNELS_FILE}",
|
||||
"squelch": squelch})
|
||||
files[CHANNELS_FILE] = channels_csv(chans)
|
||||
freqs = [c["frequency"] for c in chans]
|
||||
|
||||
source["center"] = choose_center(freqs, int(sample_rate), center_override)
|
||||
|
||||
tr = {
|
||||
"ver": 2,
|
||||
"sources": [source],
|
||||
"systems": [system],
|
||||
"tempDir": TR_TEMP_DIR,
|
||||
"captureDir": TR_CAPTURE_DIR,
|
||||
"callTimeout": CALL_TIMEOUT_SECONDS,
|
||||
# File log too (stdout still goes to docker logs): trunk_recorder.py
|
||||
# reads the control-channel decode rate from it for the health check.
|
||||
"logFile": True,
|
||||
"logDir": f"{TR_APP_DIR}/logs",
|
||||
"logColor": False,
|
||||
# Live audio for Discord: simplestream sends every call's PCM to the
|
||||
# edge-node, which follows one talkgroup at a time into PulseAudio.
|
||||
"audioStreaming": True,
|
||||
"plugins": [{
|
||||
"name": "simplestream",
|
||||
"library": "libsimplestream.so",
|
||||
"streams": [{"TGID": 0, "address": "127.0.0.1", "port": int(live_port), "sendTGID": True}],
|
||||
}],
|
||||
}
|
||||
return tr, files
|
||||
|
||||
|
||||
def live_sample_rate(system_type: str, config: Dict[str, Any]) -> int:
|
||||
"""simplestream sends analog audio at 16 kHz and digital at 8 kHz (Plugins.md)."""
|
||||
return 16000 if system_mode(system_type, config or {}) == ANALOG else 8000
|
||||
Reference in New Issue
Block a user