Merge feat/secondary-sdr-priority: every spare SDR runs the next decoder in priority order
CI / lint (push) Successful in 11s
CI / test (push) Successful in 55s
Build edge-node / build (push) Successful in 1m12s
Build secondary-sdr / build (push) Successful in 3m29s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 14:14:53 -04:00
co-authored by Claude Opus 5.5
11 changed files with 476 additions and 117 deletions
+15 -4
View File
@@ -210,10 +210,14 @@ class MQTTManager:
logger.info("No API key on disk — requesting re-delivery from C2 server.") logger.info("No API key on disk — requesting re-delivery from C2 server.")
self._publish(self._t_key_request, {}, qos=1) self._publish(self._t_key_request, {}, qos=1)
async def publish_checkin(self):
await self._publish_checkin()
async def _publish_checkin(self): async def _publish_checkin(self):
from app.internal.discord_radio import radio_bot from app.internal.discord_radio import radio_bot
from app.internal.config_manager import load_node_config from app.internal.config_manager import load_node_config
from app.internal.op25_client import op25_client from app.internal.op25_client import op25_client
from app.internal.secondary_sdr_client import secondary_sdr_client
config = load_node_config() config = load_node_config()
devices = await op25_client.devices() devices = await op25_client.devices()
payload = { payload = {
@@ -228,11 +232,18 @@ class MQTTManager:
"override_system_id": config.override_system_id, "override_system_id": config.override_system_id,
"enforce_override_timeout": config.enforce_override_timeout, "enforce_override_timeout": config.enforce_override_timeout,
"secondary_sdr_mode": config.secondary_sdr_mode, "secondary_sdr_mode": config.secondary_sdr_mode,
"secondary_sdr_priority": config.secondary_sdr_priority,
} }
# Best-effort — the first node-initiated hardware-report field. Omit secondary = await secondary_sdr_client.status()
# rather than guess if op25's control API is unreachable. if secondary is not None:
if devices is not None: payload["secondary_sdr_running"] = [r["mode"] for r in secondary.get("running", [])]
payload["sdr_count"] = devices.get("count") # Best-effort hardware report — omit rather than guess. op25's :stable
# image has no lsusb, so fall back to the secondary-sdr container's count.
count = devices.get("count") if devices else None
if count is None and secondary is not None:
count = secondary.get("sdr_count")
if count is not None:
payload["sdr_count"] = count
self._publish(self._t_checkin, payload, qos=1) self._publish(self._t_checkin, payload, qos=1)
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False): def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
@@ -0,0 +1,29 @@
import asyncio
from typing import Iterable, List, Optional
from app.internal.config_manager import load_node_config, save_node_config
from app.internal.logger import logger
from app.internal.secondary_sdr_client import secondary_sdr_client
from app.models import normalize_secondary_priority
async def set_secondary_priority(priority: Iterable[str]) -> Optional[List[str]]:
"""Persist and apply the node's secondary SDR priority — one path for the
local dashboard, a C2 command and a config push alike. Never touches op25:
changing what the spare dongles do must not interrupt P25 recording.
Returns the decoders now running (None if the container is unreachable).
"""
ordered = normalize_secondary_priority(priority)
cfg = load_node_config()
cfg.secondary_sdr_priority = ordered
cfg.secondary_sdr_mode = ordered[0] if ordered else "none"
save_node_config(cfg)
running = await secondary_sdr_client.apply(ordered)
logger.info(f"Secondary SDR priority set to {ordered!r}; running {running!r}")
# Report straight away so C2's view doesn't wait for the next heartbeat.
from app.internal.mqtt_manager import mqtt_manager
asyncio.create_task(mqtt_manager.publish_checkin())
return running
@@ -1,5 +1,5 @@
import httpx import httpx
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from app.config import settings from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
@@ -15,6 +15,18 @@ class SecondarySdrClient:
def __init__(self): def __init__(self):
self.api_url = settings.secondary_sdr_api_url self.api_url = settings.secondary_sdr_api_url
async def apply(self, priority: List[str]) -> Optional[List[str]]:
"""Run decoders down the priority list until SDRs run out. Returns the
modes actually running, or None if the container is unreachable."""
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{self.api_url}/secondary/apply", json={"priority": priority})
r.raise_for_status()
return r.json().get("running", [])
except Exception as e:
logger.error(f"Secondary SDR apply (priority={priority!r}) failed: {e}")
return None
async def start(self, mode: str) -> bool: async def start(self, mode: str) -> bool:
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
@@ -32,13 +32,14 @@ async def _post_snapshot(path: str, body: dict) -> None:
async def telemetry_uplink_loop(): async def telemetry_uplink_loop():
while True: while True:
await asyncio.sleep(UPLINK_INTERVAL_SECONDS) await asyncio.sleep(UPLINK_INTERVAL_SECONDS)
config = load_node_config() if not load_node_config().secondary_sdr_priority:
if config.secondary_sdr_mode not in ("adsb", "ais"):
continue continue
snapshot = await secondary_sdr_client.data() snapshot = await secondary_sdr_client.data()
if not snapshot: if not snapshot:
continue continue
if config.secondary_sdr_mode == "adsb" and snapshot.get("aircraft"): # Several decoders can run at once (one per spare SDR), so forward
# whatever each produced rather than keying off a single mode.
if snapshot.get("aircraft"):
await _post_snapshot("/telemetry/adsb", {"aircraft": snapshot["aircraft"]}) await _post_snapshot("/telemetry/adsb", {"aircraft": snapshot["aircraft"]})
elif config.secondary_sdr_mode == "ais" and snapshot.get("vessels"): if snapshot.get("vessels"):
await _post_snapshot("/telemetry/ais", {"vessels": snapshot["vessels"]}) await _post_snapshot("/telemetry/ais", {"vessels": snapshot["vessels"]})
+14 -12
View File
@@ -158,6 +158,9 @@ async def on_command(payload: dict):
) )
elif action == "discord_leave": elif action == "discord_leave":
await radio_bot.leave() await radio_bot.leave()
elif action == "set_secondary_priority":
from app.internal.secondary_priority import set_secondary_priority
await set_secondary_priority(payload.get("priority") or [])
elif action == "op25_restart": elif action == "op25_restart":
from app.internal.op25_client import op25_client from app.internal.op25_client import op25_client
await op25_client.stop() await op25_client.stop()
@@ -224,7 +227,8 @@ async def on_config_push(payload: dict):
hardware_preset = payload.pop("hardware_preset", None) hardware_preset = payload.pop("hardware_preset", None)
ppm_override = payload.pop("ppm_override", None) ppm_override = payload.pop("ppm_override", None)
node_type = payload.pop("node_type", None) node_type = payload.pop("node_type", None)
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None) secondary_sdr_priority = payload.pop("secondary_sdr_priority", None)
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None) # legacy single-mode C2
enforce_override_timeout = payload.pop("enforce_override_timeout", None) enforce_override_timeout = payload.pop("enforce_override_timeout", None)
try: try:
config = SystemConfig(**payload) config = SystemConfig(**payload)
@@ -244,8 +248,6 @@ async def on_config_push(payload: dict):
node_cfg.ppm_override = float(ppm_override) node_cfg.ppm_override = float(ppm_override)
if node_type is not None: if node_type is not None:
node_cfg.node_type = node_type node_cfg.node_type = node_type
if secondary_sdr_mode is not None:
node_cfg.secondary_sdr_mode = secondary_sdr_mode
if enforce_override_timeout is not None: if enforce_override_timeout is not None:
node_cfg.enforce_override_timeout = bool(enforce_override_timeout) node_cfg.enforce_override_timeout = bool(enforce_override_timeout)
save_node_config(node_cfg) save_node_config(node_cfg)
@@ -260,12 +262,11 @@ async def on_config_push(payload: dict):
await op25_client.start() await op25_client.start()
logger.info(f"Config push applied: {config.name}") logger.info(f"Config push applied: {config.name}")
if secondary_sdr_mode is not None: if secondary_sdr_priority is None and secondary_sdr_mode is not None:
from app.internal.secondary_sdr_client import secondary_sdr_client secondary_sdr_priority = [secondary_sdr_mode]
if secondary_sdr_mode in ("adsb", "ais"): if secondary_sdr_priority is not None:
await secondary_sdr_client.start(secondary_sdr_mode) from app.internal.secondary_priority import set_secondary_priority
else: await set_secondary_priority(secondary_sdr_priority)
await secondary_sdr_client.stop()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -322,10 +323,11 @@ async def lifespan(app: FastAPI):
logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…") logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
await asyncio.sleep(3) await asyncio.sleep(3)
if node_cfg.secondary_sdr_mode in ("adsb", "ais"): # After op25 has claimed its dongle, so the decoders only get the spares.
if node_cfg.secondary_sdr_priority:
from app.internal.secondary_sdr_client import secondary_sdr_client from app.internal.secondary_sdr_client import secondary_sdr_client
logger.info(f"Resuming secondary SDR (mode={node_cfg.secondary_sdr_mode!r}) after restart.") logger.info(f"Resuming secondary SDRs (priority={node_cfg.secondary_sdr_priority!r}) after restart.")
await secondary_sdr_client.start(node_cfg.secondary_sdr_mode) await secondary_sdr_client.apply(node_cfg.secondary_sdr_priority)
heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop()) heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop())
from app.internal.telemetry_uplink import telemetry_uplink_loop from app.internal.telemetry_uplink import telemetry_uplink_loop
+28 -3
View File
@@ -1,5 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel, model_validator
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, Iterable, List
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime
@@ -23,6 +23,21 @@ class SystemConfig(BaseModel):
config: Dict[str, Any] # OP25-compatible config blob passed through to op25-container config: Dict[str, Any] # OP25-compatible config blob passed through to op25-container
# Decoders a node can run on the SDRs beyond op25's (node-26#9). op25 always
# keeps its own dongle; each further dongle runs the next entry of the node's
# secondary_sdr_priority, so a 3-SDR node with ["adsb", "ais"] runs both.
SECONDARY_SDR_MODES = ("adsb", "ais")
def normalize_secondary_priority(items: Iterable[str]) -> List[str]:
"""Known modes only, first occurrence wins, order preserved."""
out: List[str] = []
for m in items or []:
if m in SECONDARY_SDR_MODES and m not in out:
out.append(m)
return out
class NodeConfig(BaseModel): class NodeConfig(BaseModel):
node_id: str node_id: str
node_name: str node_name: str
@@ -34,12 +49,22 @@ class NodeConfig(BaseModel):
hardware_preset: str = "rtl-sdr-v3" hardware_preset: str = "rtl-sdr-v3"
ppm_override: Optional[float] = None ppm_override: Optional[float] = None
node_type: str = "fixed" # fixed or portable node_type: str = "fixed" # fixed or portable
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR secondary_sdr_priority: List[str] = [] # ordered; SDRs beyond op25's run these top-down
# Legacy single-mode field (pre-priority). Still written as priority[0] so
# an older C2 reading checkins sees something sensible; read only to
# migrate a node_config.json saved before priority existed.
secondary_sdr_mode: str = "none"
enforce_override_timeout: bool = True enforce_override_timeout: bool = True
override_system_id: Optional[str] = None override_system_id: Optional[str] = None
override_config: Optional[SystemConfig] = None override_config: Optional[SystemConfig] = None
offline_call_buffer_size: int = 35 # max call_end events to buffer while MQTT is offline offline_call_buffer_size: int = 35 # max call_end events to buffer while MQTT is offline
@model_validator(mode="after")
def _migrate_secondary_mode(self) -> "NodeConfig":
if not self.secondary_sdr_priority and self.secondary_sdr_mode in SECONDARY_SDR_MODES:
self.secondary_sdr_priority = [self.secondary_sdr_mode]
return self
class CallEvent(BaseModel): class CallEvent(BaseModel):
call_id: str call_id: str
+26 -2
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Body from fastapi import APIRouter, Depends, HTTPException, Body
from typing import Optional from typing import List, Optional
import asyncio import asyncio
import httpx import httpx
from app.config import settings from app.config import settings
from app.models import SystemConfig from app.models import SystemConfig, SECONDARY_SDR_MODES
from app.internal.op25_client import op25_client from app.internal.op25_client import op25_client
from app.internal.config_manager import load_node_config, save_node_config, apply_system_config from app.internal.config_manager import load_node_config, save_node_config, apply_system_config
from app.internal.call_recorder import call_recorder from app.internal.call_recorder import call_recorder
@@ -204,6 +204,30 @@ async def ack_override(timeout_minutes: int = Body(1440)):
raise HTTPException(500, f"Failed to contact C2: {e}") raise HTTPException(500, f"Failed to contact C2: {e}")
@router.get("/secondary")
async def get_secondary():
"""Secondary SDR priority plus what's actually running (node-26#9)."""
from app.internal.secondary_sdr_client import secondary_sdr_client
status = await secondary_sdr_client.status()
return {
"priority": load_node_config().secondary_sdr_priority,
"modes": list(SECONDARY_SDR_MODES),
"running": [r["mode"] for r in status.get("running", [])] if status else None,
"sdr_count": status.get("sdr_count") if status else None,
}
@router.post("/secondary/priority")
async def set_secondary_priority(priority: List[str] = Body(..., embed=True)):
"""Set the ordered list the spare SDRs work through. Never restarts op25."""
from app.internal.secondary_priority import set_secondary_priority as apply_priority
unknown = [m for m in priority if m not in SECONDARY_SDR_MODES]
if unknown:
raise HTTPException(400, f"Unknown secondary SDR mode(s): {unknown}")
running = await apply_priority(priority)
return {"ok": True, "priority": load_node_config().secondary_sdr_priority, "running": running}
@router.post("/discord/join") @router.post("/discord/join")
async def discord_join(guild_id: int, channel_id: int): async def discord_join(guild_id: int, channel_id: int):
ok = await radio_bot.join(guild_id, channel_id) ok = await radio_bot.join(guild_id, channel_id)
+137
View File
@@ -105,6 +105,30 @@
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
.sdr-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0;
border-bottom: 1px solid var(--glass-border);
}
.sdr-row:last-child { border-bottom: none; }
.sdr-rank { width: 1.2rem; color: var(--text-muted); font-size: 0.8rem; text-align: right; }
.sdr-name { flex: 1; }
.sdr-name small { display: block; color: var(--text-muted); font-size: 0.75rem; }
.sdr-move {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--glass-border);
color: var(--text-main);
border-radius: 6px;
width: 1.9rem;
height: 1.9rem;
cursor: pointer;
}
.sdr-move:disabled { opacity: 0.3; cursor: default; }
.sdr-state { font-size: 0.75rem; min-width: 6.5rem; text-align: right; color: var(--text-muted); }
.sdr-state.on { color: var(--success); }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
@@ -360,6 +384,22 @@
</div> </div>
</div> </div>
<!-- Secondary SDRs Card (node-26#9) -->
<div class="glass-card">
<div class="card-header">
<div class="card-title">Secondary SDRs</div>
</div>
<p style="color: var(--text-muted); font-size: 0.8rem; margin: 0 0 0.5rem;">
OP25 always keeps its own SDR. Every other SDR runs the next enabled item below, top first.
<span id="sdr-summary"></span>
</p>
<div id="sdr-list"></div>
<div style="display:flex; gap:0.75rem; align-items:center; margin-top:0.75rem;">
<button id="sdr-save" class="btn btn-primary" style="padding:0.5rem 1rem;" onclick="saveSdrPriority()" disabled>Save priority</button>
<span id="sdr-msg" style="color: var(--text-muted); font-size: 0.8rem;"></span>
</div>
</div>
<!-- Local Listening Card --> <!-- Local Listening Card -->
<div class="glass-card player-card"> <div class="glass-card player-card">
<div class="card-header" style="margin-bottom:0.5rem; border:none;"> <div class="card-header" style="margin-bottom:0.5rem; border:none;">
@@ -492,6 +532,103 @@
} }
} }
// ── Secondary SDR priority ────────────────────────────────────────────
const SDR_LABELS = {
adsb: ['ADS-B', 'Aircraft · 1090 MHz'],
ais: ['AIS', 'Vessels · 162 MHz'],
};
let sdrRows = []; // [{mode, enabled}] in display order
let sdrRunning = [];
let sdrDirty = false;
function renderSdr() {
const list = document.getElementById('sdr-list');
list.innerHTML = '';
let rank = 0;
sdrRows.forEach((row, i) => {
const [name, hint] = SDR_LABELS[row.mode] || [row.mode, ''];
const running = (sdrRunning || []).includes(row.mode);
// Unsaved first: a reorder must not keep claiming the old order is live.
const state = !row.enabled ? 'Off'
: sdrDirty ? 'Unsaved'
: sdrRunning === null ? 'Unknown'
: running ? 'Running' : 'Waiting for SDR';
const el = document.createElement('div');
el.className = 'sdr-row';
el.innerHTML = `
<span class="sdr-rank">${row.enabled ? ++rank : ''}</span>
<input type="checkbox" ${row.enabled ? 'checked' : ''} aria-label="Enable ${name}">
<span class="sdr-name">${name}<small>${hint}</small></span>
<button class="sdr-move" aria-label="Move ${name} up" ${i === 0 ? 'disabled' : ''}>▲</button>
<button class="sdr-move" aria-label="Move ${name} down" ${i === sdrRows.length - 1 ? 'disabled' : ''}>▼</button>
<span class="sdr-state ${state === 'Running' ? 'on' : ''}">${state}</span>`;
const [box] = el.getElementsByTagName('input');
const [up, down] = el.getElementsByTagName('button');
box.onchange = () => { row.enabled = box.checked; markSdrDirty(); };
up.onclick = () => { [sdrRows[i - 1], sdrRows[i]] = [sdrRows[i], sdrRows[i - 1]]; markSdrDirty(); };
down.onclick = () => { [sdrRows[i + 1], sdrRows[i]] = [sdrRows[i], sdrRows[i + 1]]; markSdrDirty(); };
list.appendChild(el);
});
document.getElementById('sdr-save').disabled = !sdrDirty;
}
function markSdrDirty() {
sdrDirty = true;
document.getElementById('sdr-msg').textContent = '';
renderSdr();
}
async function loadSdr() {
if (sdrDirty) return; // never clobber unsaved edits with a poll
try {
const r = await fetch('/api/secondary');
if (!r.ok) return;
const d = await r.json();
sdrRunning = d.running; // null = secondary SDR service unreachable
sdrRows = [
...d.priority.map(mode => ({ mode, enabled: true })),
...d.modes.filter(m => !d.priority.includes(m)).map(mode => ({ mode, enabled: false })),
];
const summary = document.getElementById('sdr-summary');
if (d.running === null) {
summary.textContent = 'Secondary SDR service is not responding.';
} else if (d.sdr_count != null) {
const spare = Math.max(d.sdr_count - 1, 0);
summary.textContent = `This node has ${d.sdr_count} SDR${d.sdr_count === 1 ? '' : 's'}, so ${spare} spare.`;
}
renderSdr();
} catch (e) {
console.error('Secondary SDR load failed:', e);
}
}
async function saveSdrPriority() {
const priority = sdrRows.filter(r => r.enabled).map(r => r.mode);
const btn = document.getElementById('sdr-save');
const msg = document.getElementById('sdr-msg');
btn.disabled = true;
msg.textContent = 'Applying…';
try {
const r = await fetch('/api/secondary/priority', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priority }),
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
sdrDirty = false;
msg.textContent = d.running === null ? 'Saved, but the secondary SDR service did not respond.' : 'Saved.';
await loadSdr();
} catch (e) {
console.error('Secondary SDR save failed:', e);
msg.textContent = 'Save failed.';
btn.disabled = false;
}
}
loadSdr();
setInterval(loadSdr, 10000);
refresh(); refresh();
setInterval(refresh, 2000); // Polling every 2 seconds setInterval(refresh, 2000); // Polling every 2 seconds
</script> </script>
@@ -0,0 +1,58 @@
"""
node-26#9 — secondary SDR priority: an ordered list the SDRs beyond op25's work
through. Normalisation, migration from the legacy single-mode field, and the one
apply path shared by the dashboard, C2 commands and config pushes.
"""
import asyncio
from unittest.mock import AsyncMock, patch
from app.models import NodeConfig, normalize_secondary_priority
def _cfg(**kw) -> NodeConfig:
return NodeConfig(node_id="n1", node_name="N1", lat=0.0, lon=0.0, **kw)
def test_normalize_keeps_order_drops_unknown_and_duplicates():
assert normalize_secondary_priority(["ais", "bogus", "adsb", "ais"]) == ["ais", "adsb"]
assert normalize_secondary_priority([]) == []
def test_legacy_single_mode_migrates_to_priority():
assert _cfg(secondary_sdr_mode="adsb").secondary_sdr_priority == ["adsb"]
assert _cfg(secondary_sdr_mode="none").secondary_sdr_priority == []
def test_explicit_priority_wins_over_legacy_mode():
assert _cfg(secondary_sdr_mode="adsb", secondary_sdr_priority=["ais"]).secondary_sdr_priority == ["ais"]
def test_set_priority_persists_applies_and_never_touches_op25(tmp_path):
config_file = tmp_path / "node_config.json"
import app.internal.config_manager as cm
with patch.object(cm, "_CONFIG_FILE", config_file):
cm.save_node_config(_cfg(secondary_sdr_mode="adsb"))
from app.internal import secondary_priority as sp
with patch.object(sp.secondary_sdr_client, "apply", AsyncMock(return_value=["ais"])) as apply, \
patch("app.internal.mqtt_manager.mqtt_manager.publish_checkin", AsyncMock()), \
patch("app.internal.op25_client.op25_client.stop", AsyncMock()) as op25_stop:
running = asyncio.run(sp.set_secondary_priority(["ais", "adsb", "ais"]))
saved = cm.load_node_config()
assert running == ["ais"]
apply.assert_awaited_once_with(["ais", "adsb"])
op25_stop.assert_not_awaited()
assert saved.secondary_sdr_priority == ["ais", "adsb"]
assert saved.secondary_sdr_mode == "ais"
def test_clearing_priority_is_not_undone_by_legacy_migration(tmp_path):
config_file = tmp_path / "node_config.json"
import app.internal.config_manager as cm
with patch.object(cm, "_CONFIG_FILE", config_file):
cm.save_node_config(_cfg(secondary_sdr_mode="adsb"))
from app.internal import secondary_priority as sp
with patch.object(sp.secondary_sdr_client, "apply", AsyncMock(return_value=[])), \
patch("app.internal.mqtt_manager.mqtt_manager.publish_checkin", AsyncMock()):
asyncio.run(sp.set_secondary_priority([]))
assert cm.load_node_config().secondary_sdr_priority == []
@@ -10,64 +10,59 @@ from internal.logger import create_logger
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
# AIS-catcher streams one JSON object per received message on stdout rather # One decoder per mode, each on its own SDR (node-26#9). The node's secondary
# than writing a periodic snapshot file (dump1090's approach above) — so the # SDR *priority* (e.g. ["adsb", "ais"]) is applied by apply(): decoders start
# current-vessel snapshot lives in memory, keyed by mmsi, kept warm by a # down the list until no free dongle is left, so every SDR the node has gets
# background reader thread for as long as the subprocess we started is # used and the ones beyond the list's reach simply aren't started. OP25 always
# alive. Unlike the pgid-file state, this does NOT survive this API # keeps its own dongle — it is started first and never part of this list.
# process restarting independently of its subprocess — acceptable since MODES = ("adsb", "ais")
# nothing here does that today.
_ais_vessels: Dict[str, Dict[str, Any]] = {}
_ais_lock = threading.Lock()
# Which RTL-SDR index op25 holds is NOT fixed — on radio-box op25 had index 1 # Which RTL-SDR index op25 holds is NOT fixed — on radio-box op25 had index 1
# and index 0 was free, so "op25 is always 0" was wrong. rtlsdr can't open a # and index 0 was free, so "op25 is always 0" was wrong. rtlsdr can't open a
# dongle another process has claimed, and the decoders exit within ~50ms when # dongle another process has claimed, and the decoders exit within ~50ms when
# that happens, so start() tries each index and keeps the first that stays up. # that happens, so _start_one() tries each index and keeps the first that
# op25 is never disturbed: a failed claim doesn't touch its dongle. # stays up. op25 is never disturbed: a failed claim doesn't touch its dongle.
MAX_SDR_INDEX = 4 MAX_SDR_INDEX = 4
_STARTUP_GRACE_S = 2.0 _STARTUP_GRACE_S = 2.0
_PGID_FILE = "/tmp/secondary_sdr.pgid" _STATE_DIR = Path("/tmp/secondary_sdr")
_MODE_FILE = "/tmp/secondary_sdr.mode"
ADSB_JSON_DIR = Path("/tmp/adsb") ADSB_JSON_DIR = Path("/tmp/adsb")
# The live decoder handle. poll() is the only reliable liveness check: a # Live decoder handles. poll() is the only reliable liveness check: a decoder
# decoder that dies on startup (e.g. SDR busy) stays an unreaped zombie, and # that dies on startup stays an unreaped zombie, and killpg(pgid, 0) still
# killpg(pgid, 0) still succeeds on a zombie — status reported "running". # succeeds on a zombie.
_proc: Optional[subprocess.Popen] = None _procs: Dict[str, subprocess.Popen] = {}
_indices: Dict[str, int] = {}
_lock = threading.Lock()
# AIS-catcher streams one JSON object per received message on stdout rather
# than writing a periodic snapshot file (readsb's approach) — so the
# current-vessel snapshot lives in memory, keyed by mmsi, kept warm by a
# background reader thread for as long as the decoder is alive.
_ais_vessels: Dict[str, Dict[str, Any]] = {}
_ais_lock = threading.Lock()
def _save_state(pgid: int, mode: str) -> None: def _pgid_file(mode: str) -> Path:
Path(_PGID_FILE).write_text(str(pgid)) return _STATE_DIR / f"{mode}.pgid"
Path(_MODE_FILE).write_text(mode)
def _read_pgid() -> Optional[int]: def _reap_orphans() -> None:
"""Kill decoders left behind by a previous API process (uvicorn --reload
restarts this process, but decoders run in their own session and would
otherwise keep holding their SDRs)."""
if not _STATE_DIR.exists():
return
for f in _STATE_DIR.glob("*.pgid"):
try: try:
return int(Path(_PGID_FILE).read_text().strip()) os.killpg(int(f.read_text().strip()), signal.SIGTERM)
LOGGER.info(f"Stopped orphaned secondary decoder from {f.name}")
except Exception: except Exception:
return None pass
f.unlink(missing_ok=True)
def _read_mode() -> Optional[str]: _reap_orphans()
try:
return Path(_MODE_FILE).read_text().strip()
except Exception:
return None
def is_running() -> bool:
if _proc is not None:
return _proc.poll() is None
pgid = _read_pgid()
if pgid is None:
return False
try:
os.killpg(pgid, 0)
return True
except OSError:
return False
def _adsb_command(index: int) -> List[str]: def _adsb_command(index: int) -> List[str]:
@@ -90,6 +85,9 @@ def _ais_command(index: int) -> List[str]:
] ]
_COMMANDS = {"adsb": _adsb_command, "ais": _ais_command}
def _ais_reader(proc: subprocess.Popen) -> None: def _ais_reader(proc: subprocess.Popen) -> None:
""" """
Consume AIS-catcher's stdout, one JSON message per line, and keep the Consume AIS-catcher's stdout, one JSON message per line, and keep the
@@ -134,25 +132,32 @@ def _ais_reader(proc: subprocess.Popen) -> None:
_ais_vessels[str(mmsi)] = existing _ais_vessels[str(mmsi)] = existing
def start(mode: str) -> bool: def is_running(mode: str) -> bool:
global _proc proc = _procs.get(mode)
if is_running(): return proc is not None and proc.poll() is None
stop()
if mode == "adsb":
build = _adsb_command def running() -> List[str]:
elif mode == "ais": return [m for m in MODES if is_running(m)]
build = _ais_command
def _start_one(mode: str) -> bool:
"""Start one decoder on the first free SDR. False when none is free."""
if mode not in _COMMANDS:
raise ValueError(f"Unknown secondary SDR mode: {mode!r}")
if is_running(mode):
return True
if mode == "ais":
with _ais_lock: with _ais_lock:
_ais_vessels.clear() _ais_vessels.clear()
else:
raise ValueError(f"Unknown secondary SDR mode: {mode!r}")
needs_stdout = mode == "ais" needs_stdout = mode == "ais"
for index in range(MAX_SDR_INDEX): for index in range(MAX_SDR_INDEX):
if index in {_indices[m] for m in running() if m in _indices}:
continue
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
build(index), _COMMANDS[mode](index),
preexec_fn=os.setsid, preexec_fn=os.setsid,
stdout=subprocess.PIPE if needs_stdout else None, stdout=subprocess.PIPE if needs_stdout else None,
text=True if needs_stdout else None, text=True if needs_stdout else None,
@@ -169,46 +174,84 @@ def start(mode: str) -> bool:
pass pass
if needs_stdout: if needs_stdout:
threading.Thread(target=_ais_reader, args=(proc,), daemon=True).start() threading.Thread(target=_ais_reader, args=(proc,), daemon=True).start()
_proc = proc _procs[mode] = proc
_save_state(proc.pid, mode) _indices[mode] = index
_STATE_DIR.mkdir(parents=True, exist_ok=True)
_pgid_file(mode).write_text(str(proc.pid))
LOGGER.info(f"Started secondary SDR decoder mode={mode!r} on SDR index {index} pid={proc.pid}") LOGGER.info(f"Started secondary SDR decoder mode={mode!r} on SDR index {index} pid={proc.pid}")
return True return True
LOGGER.error(f"Secondary SDR decoder mode={mode!r}: no free SDR found (op25 holds one; is a second plugged in?)") LOGGER.info(f"Secondary SDR decoder mode={mode!r}: no free SDR left")
return False return False
def stop() -> bool: def _stop_one(mode: str) -> None:
global _proc proc = _procs.pop(mode, None)
pgid = _read_pgid() _indices.pop(mode, None)
if pgid is None: if proc is not None:
return True
try: try:
os.killpg(pgid, signal.SIGTERM) os.killpg(proc.pid, signal.SIGTERM)
except OSError: except OSError:
pass pass
if _proc is not None:
try: try:
_proc.wait(timeout=5) proc.wait(timeout=5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
_proc = None _pgid_file(mode).unlink(missing_ok=True)
def start(mode: str) -> bool:
with _lock:
return _start_one(mode)
def stop(mode: Optional[str] = None) -> None:
with _lock:
for m in [mode] if mode else list(_procs):
_stop_one(m)
def apply(priority: List[str]) -> List[str]:
"""Run decoders in priority order until SDRs run out; stop everything else.
Unchanged when the running set is already the right prefix of the list, so
re-applying the same priority (every restart/config push) is a no-op
rather than a decoder restart.
"""
for m in priority:
if m not in _COMMANDS:
raise ValueError(f"Unknown secondary SDR mode: {m!r}")
with _lock:
live = set(running())
if live and live == set(priority[:len(live)]):
# Already running the head of the list; only try to extend it.
for m in priority[len(live):]:
if not _start_one(m):
break
return running()
for m in list(_procs):
_stop_one(m)
for m in priority:
if not _start_one(m):
break
return running()
def sdr_count() -> Optional[int]:
"""RTL-SDR dongles on USB, same heuristic as install.sh. This container has
usbutils; op25's :stable image doesn't, so its /op25/devices can't answer."""
try: try:
os.remove(_PGID_FILE) out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout
except OSError: except Exception:
pass return None
try: return sum(1 for line in out.splitlines() if "0bda:2838" in line or "0bda:2832" in line)
os.remove(_MODE_FILE)
except OSError:
pass
return True
def status() -> Dict[str, Any]: def status() -> Dict[str, Any]:
running = is_running() live = running()
return { return {
"status": "running" if running else "stopped", "running": [{"mode": m, "sdr_index": _indices.get(m)} for m in live],
"mode": _read_mode() if running else None, "sdr_count": sdr_count(),
} }
@@ -250,11 +293,10 @@ def _read_adsb_snapshot() -> List[Dict[str, Any]]:
def data() -> Dict[str, Any]: def data() -> Dict[str, Any]:
mode = _read_mode()
if mode == "adsb":
return {"mode": mode, "aircraft": _read_adsb_snapshot()}
if mode == "ais":
with _ais_lock: with _ais_lock:
vessels = list(_ais_vessels.values()) vessels = list(_ais_vessels.values()) if is_running("ais") else []
return {"mode": mode, "vessels": vessels} return {
return {"mode": mode, "aircraft": [], "vessels": []} "running": running(),
"aircraft": _read_adsb_snapshot() if is_running("adsb") else [],
"vessels": vessels,
}
@@ -1,3 +1,5 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@@ -11,9 +13,25 @@ class StartBody(BaseModel):
mode: str # adsb | ais mode: str # adsb | ais
class StopBody(BaseModel):
mode: Optional[str] = None # omit to stop every decoder
class ApplyBody(BaseModel):
priority: List[str] # ordered, e.g. ["adsb", "ais"]
def create_secondary_router(): def create_secondary_router():
router = APIRouter() router = APIRouter()
@router.post("/apply")
async def apply(body: ApplyBody):
try:
live = decoder_control.apply(body.priority)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"running": live}
@router.post("/start") @router.post("/start")
async def start(body: StartBody): async def start(body: StartBody):
try: try:
@@ -21,12 +39,12 @@ def create_secondary_router():
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
if not ok: if not ok:
raise HTTPException(status_code=500, detail="Failed to start secondary SDR decoder") raise HTTPException(status_code=409, detail="No free SDR for this decoder")
return {"status": f"secondary SDR started ({body.mode})"} return {"status": f"secondary SDR started ({body.mode})"}
@router.post("/stop") @router.post("/stop")
async def stop(): async def stop(body: Optional[StopBody] = None):
decoder_control.stop() decoder_control.stop(body.mode if body else None)
return {"status": "secondary SDR stopped"} return {"status": "secondary SDR stopped"}
@router.get("/status") @router.get("/status")