From 9b50ba8114a00a5e0a738676a492eb052506d346 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 14:35:22 -0400 Subject: [PATCH 1/2] Pin each SDR service to a dongle by serial; OP25 always opens its SDR by serial Fixes node-26#11. OP25's generated config said "rtl" (= whichever dongle enumerates first), so on 2-SDR nodes a decoder could take OP25's dongle and stop recording. Now: - sdr_pins: {op25|adsb|ais: serial}, absent = automatic. Every OP25 config generation (op25_client.generate_config) rewrites the device to rtl=: the pin, else the first dongle's serial, which is what "rtl" always opened. Left as "rtl" only for unknown/shared serials. - secondary-sdr: /secondary/devices lists dongles + serials via librtlsdr (works while claimed). apply(priority, pins, reserved) never touches OP25's dongle, gives a pinned service only its own dongle, lets a higher-priority service take a spare from a lower one, and still runs a pinned lower-priority service when the top pick has no dongle. - sdr_settings.py replaces secondary_priority.py: one apply path for the local dashboard, the new set_sdr_config C2 command (set_secondary_priority kept as an alias) and config pushes. OP25 restarts only when its own dongle changes. Checkin reports sdr_devices, sdr_pins, op25_sdr_serial. - Local dashboard: 'SDRs' card with an OP25 SDR dropdown and a per-service dongle dropdown, duplicate-serial and double-pin warnings. Verified: edge-node pytest 194 passed; secondary-sdr tests 7 passed; flake8 clean; page JS passes node --check. Co-Authored-By: Claude Opus 5.5 --- drb-edge-node/app/internal/mqtt_manager.py | 8 ++ drb-edge-node/app/internal/op25_client.py | 6 +- drb-edge-node/app/internal/sdr_settings.py | 102 ++++++++++++++++ .../app/internal/secondary_priority.py | 29 ----- .../app/internal/secondary_sdr_client.py | 21 +++- drb-edge-node/app/main.py | 38 ++++-- drb-edge-node/app/models.py | 14 +++ drb-edge-node/app/routers/api.py | 45 ++++--- drb-edge-node/app/templates/index.html | 112 ++++++++++++------ drb-edge-node/tests/test_sdr_settings.py | 91 ++++++++++++++ .../tests/test_secondary_priority.py | 58 --------- .../app/internal/decoder_control.py | 109 ++++++++++++----- .../app/routers/secondary_controller.py | 10 +- .../tests/test_decoder_control.py | 84 +++++++++++++ 14 files changed, 544 insertions(+), 183 deletions(-) create mode 100644 drb-edge-node/app/internal/sdr_settings.py delete mode 100644 drb-edge-node/app/internal/secondary_priority.py create mode 100644 drb-edge-node/tests/test_sdr_settings.py delete mode 100644 drb-edge-node/tests/test_secondary_priority.py create mode 100644 secondary-sdr-container/tests/test_decoder_control.py diff --git a/drb-edge-node/app/internal/mqtt_manager.py b/drb-edge-node/app/internal/mqtt_manager.py index 81efc70..73aba28 100644 --- a/drb-edge-node/app/internal/mqtt_manager.py +++ b/drb-edge-node/app/internal/mqtt_manager.py @@ -234,9 +234,17 @@ class MQTTManager: "secondary_sdr_mode": config.secondary_sdr_mode, "secondary_sdr_priority": config.secondary_sdr_priority, } + payload["sdr_pins"] = config.sdr_pins secondary = await secondary_sdr_client.status() if secondary is not None: payload["secondary_sdr_running"] = [r["mode"] for r in secondary.get("running", [])] + if secondary.get("devices") is not None: + payload["sdr_devices"] = [ + {k: d.get(k) for k in ("index", "serial", "name", "duplicate_serial")} + for d in secondary["devices"] + ] + from app.internal.sdr_settings import op25_serial + payload["op25_sdr_serial"] = op25_serial(config, secondary["devices"]) # Best-effort hardware report — omit rather than guess. Prefer the # secondary-sdr container's count: op25's :stable image has no lsusb and # its /op25/devices answers 0 rather than "unknown" when that fails. A diff --git a/drb-edge-node/app/internal/op25_client.py b/drb-edge-node/app/internal/op25_client.py index a1cc0c7..c7956d7 100644 --- a/drb-edge-node/app/internal/op25_client.py +++ b/drb-edge-node/app/internal/op25_client.py @@ -80,10 +80,14 @@ class OP25Client: async with httpx.AsyncClient(timeout=10) as client: r = await client.post(f"{self.api_url}/op25/generate-config", json=config) r.raise_for_status() - return True except Exception as e: logger.error(f"OP25 generate-config failed: {e}") return False + # Every generated config opens its dongle by serial, never "first + # found" (node-26#11) — here so no generation path can skip it. + from app.internal.sdr_settings import pin_op25_device + await pin_op25_device() + return True async def poll_terminal(self) -> Optional[TerminalUpdate]: """ diff --git a/drb-edge-node/app/internal/sdr_settings.py b/drb-edge-node/app/internal/sdr_settings.py new file mode 100644 index 0000000..8d58369 --- /dev/null +++ b/drb-edge-node/app/internal/sdr_settings.py @@ -0,0 +1,102 @@ +""" +Which SDR does what on this node (node-26#9, node-26#11). + +- OP25 always has exactly one dongle. `sdr_pins["op25"]` names it by serial; + unpinned, OP25 keeps its historical "first dongle" — but that dongle is now + named by serial too, so the decoders can never take it out from under OP25. +- Every other dongle runs the next enabled service in `secondary_sdr_priority`. + `sdr_pins[mode]` optionally binds a service to the dongle carrying its + antenna; unpinned services take any spare. + +One apply path for the local dashboard, C2 commands and config pushes. +""" +import asyncio +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from app.config import settings +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 NodeConfig, normalize_sdr_pins, normalize_secondary_priority + +_OP25_CONFIG = Path(settings.config_path) / "active.cfg.json" + + +def op25_serial(cfg: NodeConfig, devs: Optional[List[Dict[str, Any]]]) -> Optional[str]: + """The serial of OP25's dongle: the pin, else the first detected dongle + (what OP25's plain "rtl" device string has always opened).""" + if cfg.sdr_pins.get("op25"): + return cfg.sdr_pins["op25"] + return devs[0]["serial"] if devs else None + + +async def pin_op25_device() -> Optional[str]: + """Rewrite OP25's generated config to open its dongle by serial. + + Runs after every generate-config (op25_client.generate_config). A plain + "rtl" means "device 0", and which dongle is device 0 depends on who opened + what first — that is how OP25 lost its SDR to readsb on 2026-09-27. + Left as "rtl" only when the serial is unknown or shared by two dongles. + """ + devs = await secondary_sdr_client.devices() + serial = op25_serial(load_node_config(), devs) + if not serial or (devs and sum(d["serial"] == serial for d in devs) > 1): + return None + try: + cfg = json.loads(_OP25_CONFIG.read_text()) + for dev in cfg.get("devices", []): + dev["args"] = f"rtl={serial}" + _OP25_CONFIG.write_text(json.dumps(cfg, indent=2)) + except Exception as e: + logger.error(f"Could not pin OP25 to SDR {serial}: {e}") + return None + return serial + + +async def apply_secondaries() -> Optional[List[str]]: + """Start/stop decoders to match the saved priority and pins, never on OP25's dongle.""" + cfg = load_node_config() + if not cfg.secondary_sdr_priority: + return await secondary_sdr_client.apply([], {}, []) + devs = await secondary_sdr_client.devices() + reserved = [s for s in [op25_serial(cfg, devs)] if s] + pins = {m: s for m, s in cfg.sdr_pins.items() if m != "op25"} + return await secondary_sdr_client.apply(cfg.secondary_sdr_priority, pins, reserved) + + +async def set_sdr_settings(priority: Optional[Iterable[str]] = None, + pins: Optional[Dict[str, Optional[str]]] = None) -> Optional[List[str]]: + """Persist and apply priority and/or pins. OP25 restarts only if its own + dongle changed; reordering the spare dongles never interrupts P25. + + Returns the decoders now running (None if the container is unreachable). + """ + cfg = load_node_config() + old_op25 = cfg.sdr_pins.get("op25") + if priority is not None: + ordered = normalize_secondary_priority(priority) + cfg.secondary_sdr_priority = ordered + cfg.secondary_sdr_mode = ordered[0] if ordered else "none" + if pins is not None: + cfg.sdr_pins = normalize_sdr_pins(pins) + save_node_config(cfg) + + if cfg.sdr_pins.get("op25") != old_op25: + from app.internal.op25_client import op25_client + # Free the new dongle first if a decoder holds it, then move OP25. + await secondary_sdr_client.apply([], {}, []) + serial = await pin_op25_device() + await op25_client.stop() + await asyncio.sleep(2) + await op25_client.start() + logger.info(f"OP25 moved to SDR {serial or 'rtl (first dongle)'}") + + running = await apply_secondaries() + logger.info(f"SDR settings: priority={cfg.secondary_sdr_priority!r} pins={cfg.sdr_pins!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 diff --git a/drb-edge-node/app/internal/secondary_priority.py b/drb-edge-node/app/internal/secondary_priority.py deleted file mode 100644 index 4df8a5f..0000000 --- a/drb-edge-node/app/internal/secondary_priority.py +++ /dev/null @@ -1,29 +0,0 @@ -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 diff --git a/drb-edge-node/app/internal/secondary_sdr_client.py b/drb-edge-node/app/internal/secondary_sdr_client.py index 5e6b06e..3fab60e 100644 --- a/drb-edge-node/app/internal/secondary_sdr_client.py +++ b/drb-edge-node/app/internal/secondary_sdr_client.py @@ -15,12 +15,25 @@ class SecondarySdrClient: def __init__(self): 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.""" + async def devices(self) -> Optional[List[Dict[str, Any]]]: + """Every RTL-SDR on the node with its serial, or None if unreachable.""" + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{self.api_url}/secondary/devices") + r.raise_for_status() + return r.json().get("devices", []) + except Exception as e: + logger.error(f"Secondary SDR device list failed: {e}") + return None + + async def apply(self, priority: List[str], pins: Dict[str, str], reserved: List[str]) -> Optional[List[str]]: + """Run decoders down the priority list until SDRs run out, honouring + pins and never touching `reserved` (op25's) dongles. Returns the modes + actually running, or None if the container is unreachable.""" + body = {"priority": priority, "pins": pins, "reserved": reserved} try: async with httpx.AsyncClient(timeout=30) as client: - r = await client.post(f"{self.api_url}/secondary/apply", json={"priority": priority}) + r = await client.post(f"{self.api_url}/secondary/apply", json=body) r.raise_for_status() return r.json().get("running", []) except Exception as e: diff --git a/drb-edge-node/app/main.py b/drb-edge-node/app/main.py index 58c9bf1..eadb50e 100644 --- a/drb-edge-node/app/main.py +++ b/drb-edge-node/app/main.py @@ -5,7 +5,7 @@ from typing import Optional from fastapi import FastAPI from app.config import settings -from app.models import SystemConfig +from app.models import SystemConfig, normalize_sdr_pins, normalize_secondary_priority from app.internal.logger import logger from app.internal.mqtt_manager import mqtt_manager from app.internal import credentials @@ -158,9 +158,12 @@ async def on_command(payload: dict): ) elif action == "discord_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 in ("set_sdr_config", "set_secondary_priority"): + from app.internal.sdr_settings import set_sdr_settings + try: + await set_sdr_settings(payload.get("priority"), payload.get("pins")) + except ValueError as e: + logger.error(f"Rejected SDR settings from C2: {e}") elif action == "op25_restart": from app.internal.op25_client import op25_client await op25_client.stop() @@ -228,6 +231,7 @@ async def on_config_push(payload: dict): ppm_override = payload.pop("ppm_override", None) node_type = payload.pop("node_type", None) secondary_sdr_priority = payload.pop("secondary_sdr_priority", None) + sdr_pins = payload.pop("sdr_pins", None) secondary_sdr_mode = payload.pop("secondary_sdr_mode", None) # legacy single-mode C2 enforce_override_timeout = payload.pop("enforce_override_timeout", None) try: @@ -250,6 +254,16 @@ async def on_config_push(payload: dict): node_cfg.node_type = node_type if enforce_override_timeout is not None: node_cfg.enforce_override_timeout = bool(enforce_override_timeout) + if secondary_sdr_priority is None and secondary_sdr_mode is not None: + secondary_sdr_priority = [secondary_sdr_mode] + if secondary_sdr_priority is not None: + node_cfg.secondary_sdr_priority = normalize_secondary_priority(secondary_sdr_priority) + node_cfg.secondary_sdr_mode = (node_cfg.secondary_sdr_priority or ["none"])[0] + if sdr_pins is not None: + try: + node_cfg.sdr_pins = normalize_sdr_pins(sdr_pins) + except ValueError as e: + logger.error(f"Ignoring invalid sdr_pins in config push: {e}") save_node_config(node_cfg) from app.internal.op25_client import op25_client @@ -257,16 +271,16 @@ async def on_config_push(payload: dict): logger.error(f"Failed to generate OP25 config for {config.name}") return + # OP25's (pinned) dongle may be one a decoder holds right now: free the + # spares, restart OP25 on its own dongle, then hand the rest back out. + from app.internal.secondary_sdr_client import secondary_sdr_client + from app.internal.sdr_settings import apply_secondaries + await secondary_sdr_client.apply([], {}, []) await op25_client.stop() await asyncio.sleep(2) await op25_client.start() logger.info(f"Config push applied: {config.name}") - - if secondary_sdr_priority is None and secondary_sdr_mode is not None: - secondary_sdr_priority = [secondary_sdr_mode] - if secondary_sdr_priority is not None: - from app.internal.secondary_priority import set_secondary_priority - await set_secondary_priority(secondary_sdr_priority) + await apply_secondaries() # --------------------------------------------------------------------------- @@ -325,9 +339,9 @@ async def lifespan(app: FastAPI): # 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.sdr_settings import apply_secondaries logger.info(f"Resuming secondary SDRs (priority={node_cfg.secondary_sdr_priority!r}) after restart.") - await secondary_sdr_client.apply(node_cfg.secondary_sdr_priority) + await apply_secondaries() heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop()) from app.internal.telemetry_uplink import telemetry_uplink_loop diff --git a/drb-edge-node/app/models.py b/drb-edge-node/app/models.py index 703edea..bd06fb2 100644 --- a/drb-edge-node/app/models.py +++ b/drb-edge-node/app/models.py @@ -38,6 +38,19 @@ def normalize_secondary_priority(items: Iterable[str]) -> List[str]: return out +# Services that can be bound to a specific dongle by its USB serial. +SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES + + +def normalize_sdr_pins(pins: Dict[str, Optional[str]]) -> Dict[str, str]: + """Known services only, blank = automatic (dropped). Two services can't + share a dongle, so a serial claimed twice raises.""" + out = {k: str(v).strip() for k, v in (pins or {}).items() if k in SDR_PIN_KEYS and v and str(v).strip()} + if len(set(out.values())) != len(out): + raise ValueError("Two services can't be pinned to the same SDR.") + return out + + class NodeConfig(BaseModel): node_id: str node_name: str @@ -50,6 +63,7 @@ class NodeConfig(BaseModel): ppm_override: Optional[float] = None node_type: str = "fixed" # fixed or portable secondary_sdr_priority: List[str] = [] # ordered; SDRs beyond op25's run these top-down + sdr_pins: Dict[str, str] = {} # service (op25/adsb/ais) -> dongle serial; absent = automatic # 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. diff --git a/drb-edge-node/app/routers/api.py b/drb-edge-node/app/routers/api.py index 50ec6d5..7cbf696 100644 --- a/drb-edge-node/app/routers/api.py +++ b/drb-edge-node/app/routers/api.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Body -from typing import List, Optional +from pydantic import BaseModel +from typing import Dict, List, Optional import asyncio import httpx from app.config import settings @@ -204,28 +205,42 @@ async def ack_override(timeout_minutes: int = Body(1440)): 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).""" +@router.get("/sdr") +async def get_sdr(): + """Which SDR does what: detected dongles, pins, priority, what's running.""" from app.internal.secondary_sdr_client import secondary_sdr_client + from app.internal.sdr_settings import op25_serial + cfg = load_node_config() + devs = await secondary_sdr_client.devices() status = await secondary_sdr_client.status() return { - "priority": load_node_config().secondary_sdr_priority, + "devices": devs, # None = secondary-sdr service unreachable + "op25_serial": op25_serial(cfg, devs), + "pins": cfg.sdr_pins, + "priority": cfg.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} +class SdrSettingsBody(BaseModel): + priority: Optional[List[str]] = None + pins: Optional[Dict[str, Optional[str]]] = None # service -> serial; null/"" = automatic + + +@router.post("/sdr") +async def set_sdr(body: SdrSettingsBody): + """Set priority and/or pins. Restarts OP25 only if OP25's own SDR changes.""" + from app.internal.sdr_settings import set_sdr_settings + if body.priority is not None: + unknown = [m for m in body.priority if m not in SECONDARY_SDR_MODES] + if unknown: + raise HTTPException(400, f"Unknown secondary SDR mode(s): {unknown}") + try: + running = await set_sdr_settings(body.priority, body.pins) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"ok": True, "running": running} @router.post("/discord/join") diff --git a/drb-edge-node/app/templates/index.html b/drb-edge-node/app/templates/index.html index ed8f6b2..de7e04e 100644 --- a/drb-edge-node/app/templates/index.html +++ b/drb-edge-node/app/templates/index.html @@ -128,6 +128,16 @@ .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); } + .sdr-select { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + color: var(--text-main); + border-radius: 6px; + padding: 0.3rem 0.4rem; + font-size: 0.8rem; + max-width: 13rem; + } + .sdr-select option { background: #111827; } .grid { display: grid; @@ -384,18 +394,24 @@ - +
-
Secondary SDRs
+
SDRs
+
+ OP25 SDR + +
+

- OP25 always keeps its own SDR. Every other SDR runs the next enabled item below, top first. - + Every other SDR runs the next enabled service, top first. Pin a service to the SDR + that has its antenna, or leave it on "Any spare SDR".

+
- +
@@ -532,44 +548,80 @@ } } - // ── Secondary SDR priority ──────────────────────────────────────────── + // ── SDRs: OP25's dongle, secondary priority and per-service pins ──────── const SDR_LABELS = { adsb: ['ADS-B', 'Aircraft · 1090 MHz'], ais: ['AIS', 'Vessels · 162 MHz'], }; - let sdrRows = []; // [{mode, enabled}] in display order - let sdrRunning = []; + let sdr = null; // last GET /api/sdr + let sdrRows = []; // [{mode, enabled, pin}] in display order + let sdrOp25Pin = ''; // '' = automatic let sdrDirty = false; + function esc(v) { + return String(v ?? '').replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + } + + function deviceOptions(selected, autoLabel) { + const devs = sdr.devices || []; + const known = devs.some(d => d.serial === selected); + return `` + + devs.map(d => ``).join('') + + (selected && !known ? `` : ''); + } + function renderSdr() { + const op25 = document.getElementById('sdr-op25'); + op25.innerHTML = deviceOptions(sdrOp25Pin, 'Automatic (first SDR)'); + op25.onchange = () => { sdrOp25Pin = op25.value; markSdrDirty(); }; + document.getElementById('sdr-op25-note').textContent = sdrOp25Pin !== (sdr.pins.op25 || '') + ? 'Saving will restart OP25 on the selected SDR.' + : (sdr.op25_serial ? `OP25 is using serial ${sdr.op25_serial}.` : ''); + const list = document.getElementById('sdr-list'); list.innerHTML = ''; let rank = 0; + const running = sdr.running || []; 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 pinMissing = row.pin && !(sdr.devices || []).some(d => d.serial === row.pin); const state = !row.enabled ? 'Off' : sdrDirty ? 'Unsaved' - : sdrRunning === null ? 'Unknown' - : running ? 'Running' : 'Waiting for SDR'; + : sdr.running === null ? 'Unknown' + : running.includes(row.mode) ? 'Running' + : pinMissing ? 'Pinned SDR missing' : 'Waiting for SDR'; const el = document.createElement('div'); el.className = 'sdr-row'; el.innerHTML = ` ${row.enabled ? ++rank : ''} ${name}${hint} + ${state}`; const [box] = el.getElementsByTagName('input'); + const [pick] = el.getElementsByTagName('select'); const [up, down] = el.getElementsByTagName('button'); box.onchange = () => { row.enabled = box.checked; markSdrDirty(); }; + pick.onchange = () => { row.pin = pick.value; 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; + + const warn = document.getElementById('sdr-warning'); + const pinned = [sdrOp25Pin, ...sdrRows.map(r => r.pin)].filter(Boolean); + const problems = []; + if (sdr.devices === null) problems.push('The secondary SDR service is not responding, so SDRs can\'t be listed.'); + if ((sdr.devices || []).some(d => d.duplicate_serial)) { + problems.push('Two SDRs share a serial number, so they can\'t be told apart. Give each a unique serial (rtl_eeprom -s) before pinning.'); + } + if (new Set(pinned).size !== pinned.length) problems.push('Two services are pinned to the same SDR.'); + warn.textContent = problems.join(' '); + warn.style.display = problems.length ? 'block' : 'none'; + document.getElementById('sdr-save').disabled = !sdrDirty || new Set(pinned).size !== pinned.length; } function markSdrDirty() { @@ -581,47 +633,41 @@ async function loadSdr() { if (sdrDirty) return; // never clobber unsaved edits with a poll try { - const r = await fetch('/api/secondary'); + const r = await fetch('/api/sdr'); if (!r.ok) return; - const d = await r.json(); - sdrRunning = d.running; // null = secondary SDR service unreachable + sdr = await r.json(); + sdrOp25Pin = sdr.pins.op25 || ''; sdrRows = [ - ...d.priority.map(mode => ({ mode, enabled: true })), - ...d.modes.filter(m => !d.priority.includes(m)).map(mode => ({ mode, enabled: false })), + ...sdr.priority.map(mode => ({ mode, enabled: true, pin: sdr.pins[mode] || '' })), + ...sdr.modes.filter(m => !sdr.priority.includes(m)).map(mode => ({ mode, enabled: false, pin: sdr.pins[mode] || '' })), ]; - 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); + console.error('SDR load failed:', e); } } - async function saveSdrPriority() { - const priority = sdrRows.filter(r => r.enabled).map(r => r.mode); + async function saveSdr() { + const pins = { op25: sdrOp25Pin || null }; + sdrRows.forEach(r => { pins[r.mode] = r.pin || null; }); 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', { + const r = await fetch('/api/sdr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ priority }), + body: JSON.stringify({ priority: sdrRows.filter(r => r.enabled).map(r => r.mode), pins }), }); - if (!r.ok) throw new Error(await r.text()); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); 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.'; + console.error('SDR save failed:', e); + msg.textContent = `Save failed: ${e.message}`; btn.disabled = false; } } diff --git a/drb-edge-node/tests/test_sdr_settings.py b/drb-edge-node/tests/test_sdr_settings.py new file mode 100644 index 0000000..4eabc16 --- /dev/null +++ b/drb-edge-node/tests/test_sdr_settings.py @@ -0,0 +1,91 @@ +""" +node-26#9 / #11 — which SDR does what: secondary priority, per-service pins, +and OP25 always opening its dongle by serial. +""" +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from app.models import NodeConfig, normalize_sdr_pins, normalize_secondary_priority + +DEVS = [ + {"index": 0, "serial": "69420", "name": "RTL", "duplicate_serial": False}, + {"index": 1, "serial": "00000001", "name": "RTL", "duplicate_serial": False}, +] + + +def _cfg(**kw) -> NodeConfig: + return NodeConfig(node_id="n1", node_name="N1", lat=0.0, lon=0.0, **kw) + + +def test_normalize_priority_keeps_order_drops_unknown_and_duplicates(): + assert normalize_secondary_priority(["ais", "bogus", "adsb", "ais"]) == ["ais", "adsb"] + + +def test_legacy_single_mode_migrates_to_priority(): + assert _cfg(secondary_sdr_mode="adsb").secondary_sdr_priority == ["adsb"] + assert _cfg(secondary_sdr_mode="adsb", secondary_sdr_priority=["ais"]).secondary_sdr_priority == ["ais"] + + +def test_pins_drop_blanks_and_unknown_services(): + assert normalize_sdr_pins({"op25": "00000001", "adsb": "", "ais": None, "x": "1"}) == {"op25": "00000001"} + + +def test_two_services_cannot_share_a_dongle(): + with pytest.raises(ValueError): + normalize_sdr_pins({"op25": "69420", "adsb": "69420"}) + + +@pytest.fixture +def node(tmp_path): + """Isolated node_config.json + OP25 active.cfg.json, mocked decoders/op25/mqtt.""" + import app.internal.config_manager as cm + from app.internal import sdr_settings as ss + op25_cfg = tmp_path / "active.cfg.json" + op25_cfg.write_text(json.dumps({"devices": [{"args": "rtl", "name": "sdr"}]})) + with patch.object(cm, "_CONFIG_FILE", tmp_path / "node_config.json"), \ + patch.object(ss, "_OP25_CONFIG", op25_cfg), \ + patch.object(ss.secondary_sdr_client, "devices", AsyncMock(return_value=DEVS)), \ + patch.object(ss.secondary_sdr_client, "apply", AsyncMock(return_value=["adsb"])) as apply, \ + patch("app.internal.op25_client.op25_client.stop", AsyncMock()) as op25_stop, \ + patch("app.internal.op25_client.op25_client.start", AsyncMock()), \ + patch("app.internal.mqtt_manager.mqtt_manager.publish_checkin", AsyncMock()), \ + patch("asyncio.sleep", AsyncMock()): + cm.save_node_config(_cfg()) + yield {"cm": cm, "ss": ss, "apply": apply, "op25_stop": op25_stop, + "op25_args": lambda: json.loads(op25_cfg.read_text())["devices"][0]["args"]} + + +def test_unpinned_op25_is_still_opened_by_serial_of_the_first_dongle(node): + assert asyncio.run(node["ss"].pin_op25_device()) == "69420" + assert node["op25_args"]() == "rtl=69420" + + +def test_pinned_op25_opens_its_pinned_dongle(node): + node["cm"].save_node_config(_cfg(sdr_pins={"op25": "00000001"})) + asyncio.run(node["ss"].pin_op25_device()) + assert node["op25_args"]() == "rtl=00000001" + + +def test_shared_serial_leaves_op25_on_plain_rtl(node): + dup = [dict(d, serial="00000001") for d in DEVS] + with patch.object(node["ss"].secondary_sdr_client, "devices", AsyncMock(return_value=dup)): + assert asyncio.run(node["ss"].pin_op25_device()) is None + assert node["op25_args"]() == "rtl" + + +def test_priority_change_never_restarts_op25_and_reserves_its_dongle(node): + node["cm"].save_node_config(_cfg(sdr_pins={"op25": "00000001"})) + asyncio.run(node["ss"].set_sdr_settings(priority=["adsb", "ais"])) + node["op25_stop"].assert_not_awaited() + node["apply"].assert_awaited_with(["adsb", "ais"], {}, ["00000001"]) + + +def test_moving_op25_restarts_it_on_the_new_dongle(node): + asyncio.run(node["ss"].set_sdr_settings(priority=["adsb"], pins={"op25": "00000001", "adsb": "69420"})) + node["op25_stop"].assert_awaited_once() + assert node["op25_args"]() == "rtl=00000001" + node["apply"].assert_awaited_with(["adsb"], {"adsb": "69420"}, ["00000001"]) + assert node["cm"].load_node_config().sdr_pins == {"op25": "00000001", "adsb": "69420"} diff --git a/drb-edge-node/tests/test_secondary_priority.py b/drb-edge-node/tests/test_secondary_priority.py deleted file mode 100644 index fc70782..0000000 --- a/drb-edge-node/tests/test_secondary_priority.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -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 == [] diff --git a/secondary-sdr-container/app/internal/decoder_control.py b/secondary-sdr-container/app/internal/decoder_control.py index 2063c29..3d4a568 100644 --- a/secondary-sdr-container/app/internal/decoder_control.py +++ b/secondary-sdr-container/app/internal/decoder_control.py @@ -1,3 +1,5 @@ +import ctypes +import ctypes.util import json import os import signal @@ -141,8 +143,9 @@ def running() -> List[str]: return [m for m in MODES if is_running(m)] -def _start_one(mode: str) -> bool: - """Start one decoder on the first free SDR. False when none is free.""" +def _start_one(mode: str, candidates: Optional[List[int]] = None) -> bool: + """Start one decoder on the first free SDR among `candidates` (default: + every index). False when none is free.""" if mode not in _COMMANDS: raise ValueError(f"Unknown secondary SDR mode: {mode!r}") if is_running(mode): @@ -152,7 +155,7 @@ def _start_one(mode: str) -> bool: _ais_vessels.clear() needs_stdout = mode == "ais" - for index in range(MAX_SDR_INDEX): + for index in candidates if candidates is not None else range(MAX_SDR_INDEX): if index in {_indices[m] for m in running() if m in _indices}: continue try: @@ -211,47 +214,95 @@ def stop(mode: Optional[str] = None) -> None: _stop_one(m) -def apply(priority: List[str]) -> List[str]: +def _candidates(mode: str, pins: Dict[str, str], reserved: List[str], devs: List[Dict[str, Any]]) -> List[int]: + """SDR indices `mode` may use. A pinned mode gets exactly its dongle; an + unpinned one gets any dongle that isn't op25's (reserved) or pinned to + another service. Without enumeration, fall back to probing every index.""" + if not devs: + return [] if pins.get(mode) else list(range(MAX_SDR_INDEX)) + if pins.get(mode): + idx = _index_of(pins[mode], devs) + return [] if idx is None else [idx] + taken = set(reserved) | {s for m, s in pins.items() if m != mode and s} + return [d["index"] for d in devs if d["serial"] not in taken] + + +def apply(priority: List[str], pins: Optional[Dict[str, str]] = None, + reserved: Optional[List[str]] = None) -> 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. + `pins` maps a mode to the serial of the dongle carrying its antenna; + `reserved` lists serials no decoder may touch (op25's). Pins only bind + enabled modes — a disabled service's dongle is free for the others. + Unchanged when the right decoders already run on allowed dongles, so + re-applying the same settings is a no-op rather than a restart. """ for m in priority: if m not in _COMMANDS: raise ValueError(f"Unknown secondary SDR mode: {m!r}") + pins = {m: s for m, s in (pins or {}).items() if m in priority and s} + reserved = [s for s in (reserved or []) if s] + devs = devices() 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 + for m in running(): + if m not in priority or _indices.get(m) not in _candidates(m, pins, reserved, devs): + _stop_one(m) + for i, m in enumerate(priority): + if m in running(): + continue + cands = _candidates(m, pins, reserved, devs) + if _start_one(m, cands): + continue + # Out of free dongles: take one from the lowest-priority decoder + # holding a dongle this mode may use, which then gets its own turn. + lower = [x for x in priority[i + 1:] if x in running() and _indices.get(x) in cands] + if lower: + _stop_one(lower[-1]) + _start_one(m, cands) 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.""" +def devices() -> List[Dict[str, Any]]: + """Every RTL-SDR on the node with its USB serial — readable even while a + dongle is claimed (op25's included), since it doesn't open the device. + Cheap dongles often ship with the same serial (00000001); those are + flagged, because pinning a service to a shared serial is ambiguous.""" try: - out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout - except Exception: - return None - return sum(1 for line in out.splitlines() if "0bda:2838" in line or "0bda:2832" in line) + lib = ctypes.CDLL(ctypes.util.find_library("rtlsdr") or "librtlsdr.so.0") + lib.rtlsdr_get_device_name.restype = ctypes.c_char_p + out = [] + for i in range(lib.rtlsdr_get_device_count()): + manufact, product, serial = (ctypes.create_string_buffer(256) for _ in range(3)) + lib.rtlsdr_get_device_usb_strings(i, manufact, product, serial) + out.append({ + "index": i, + "serial": serial.value.decode(errors="replace") or None, + "name": (lib.rtlsdr_get_device_name(i) or b"").decode(errors="replace"), + }) + except Exception as e: + LOGGER.warning(f"SDR enumeration failed: {e}") + return [] + serials = [d["serial"] for d in out] + for d in out: + d["duplicate_serial"] = d["serial"] is not None and serials.count(d["serial"]) > 1 + return out + + +def _index_of(serial: str, devs: List[Dict[str, Any]]) -> Optional[int]: + """Index of a uniquely-identified serial; None if absent or ambiguous.""" + matches = [d["index"] for d in devs if d["serial"] == serial] + return matches[0] if len(matches) == 1 else None def status() -> Dict[str, Any]: - live = running() + devs = devices() + by_index = {d["index"]: d["serial"] for d in devs} return { - "running": [{"mode": m, "sdr_index": _indices.get(m)} for m in live], - "sdr_count": sdr_count(), + "running": [ + {"mode": m, "sdr_index": _indices.get(m), "serial": by_index.get(_indices.get(m))} for m in running() + ], + "sdr_count": len(devs) if devs else None, + "devices": devs, } diff --git a/secondary-sdr-container/app/routers/secondary_controller.py b/secondary-sdr-container/app/routers/secondary_controller.py index f94117c..23b98db 100644 --- a/secondary-sdr-container/app/routers/secondary_controller.py +++ b/secondary-sdr-container/app/routers/secondary_controller.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Dict, List, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel @@ -19,6 +19,8 @@ class StopBody(BaseModel): class ApplyBody(BaseModel): priority: List[str] # ordered, e.g. ["adsb", "ais"] + pins: Dict[str, str] = {} # mode -> serial of the dongle with its antenna + reserved: List[str] = [] # serials no decoder may touch (op25's) def create_secondary_router(): @@ -27,7 +29,7 @@ def create_secondary_router(): @router.post("/apply") async def apply(body: ApplyBody): try: - live = decoder_control.apply(body.priority) + live = decoder_control.apply(body.priority, body.pins, body.reserved) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return {"running": live} @@ -51,6 +53,10 @@ def create_secondary_router(): async def get_status(): return decoder_control.status() + @router.get("/devices") + async def get_devices(): + return {"devices": decoder_control.devices()} + @router.get("/data") async def get_data(): return decoder_control.data() diff --git a/secondary-sdr-container/tests/test_decoder_control.py b/secondary-sdr-container/tests/test_decoder_control.py new file mode 100644 index 0000000..a2ae3a6 --- /dev/null +++ b/secondary-sdr-container/tests/test_decoder_control.py @@ -0,0 +1,84 @@ +""" +node-26#11 — which dongle each secondary decoder may use. + +Run from secondary-sdr-container/: PYTHONPATH=app python -m pytest -q tests +""" +from unittest.mock import patch + +import pytest + +from internal import decoder_control as dc + +# radio-box's real pair: op25's dongle and the one with the 1090 antenna. +DEVS = [ + {"index": 0, "serial": "69420", "name": "RTL", "duplicate_serial": False}, + {"index": 1, "serial": "00000001", "name": "RTL", "duplicate_serial": False}, +] + + +def test_unpinned_mode_never_gets_op25s_dongle(): + assert dc._candidates("adsb", {}, ["00000001"], DEVS) == [0] + + +def test_pinned_mode_gets_exactly_its_dongle(): + assert dc._candidates("ais", {"ais": "69420"}, ["00000001"], DEVS) == [0] + + +def test_unpinned_mode_skips_a_dongle_pinned_to_another_service(): + assert dc._candidates("ais", {"adsb": "69420"}, ["00000001"], DEVS) == [] + + +def test_missing_or_ambiguous_pin_gets_nothing(): + assert dc._candidates("adsb", {"adsb": "nope"}, [], DEVS) == [] + dup = [dict(d, serial="00000001") for d in DEVS] + assert dc._candidates("adsb", {"adsb": "00000001"}, [], dup) == [] + + +class FakeDecoders: + """Stands in for real processes: one decoder per free index.""" + + def __init__(self): + self.live = {} + + def start(self, mode, candidates=None): + free = [i for i in candidates if i not in self.live.values()] + if not free: + return False + self.live[mode] = free[0] + return True + + def stop(self, mode): + self.live.pop(mode, None) + + +@pytest.fixture +def fake(): + f = FakeDecoders() + with patch.object(dc, "devices", return_value=DEVS), \ + patch.object(dc, "_start_one", side_effect=f.start), \ + patch.object(dc, "_stop_one", side_effect=f.stop), \ + patch.object(dc, "running", side_effect=lambda: [m for m in dc.MODES if m in f.live]), \ + patch.dict(dc._indices, clear=True): + dc._indices.update(f.live) + yield f + + +def _apply(fake, priority, pins=None): + dc.apply(priority, pins, ["00000001"]) + dc._indices.clear() + dc._indices.update(fake.live) + return fake.live + + +def test_one_spare_goes_to_the_top_pick(fake): + assert _apply(fake, ["ais", "adsb"]) == {"ais": 0} + + +def test_reordering_hands_the_spare_to_the_new_top_pick(fake): + _apply(fake, ["adsb", "ais"]) + assert _apply(fake, ["ais", "adsb"]) == {"ais": 0} + + +def test_pinned_lower_priority_still_runs_when_top_pick_has_no_dongle(fake): + # AIS ranked first but its only candidate is ADS-B's pinned antenna dongle. + assert _apply(fake, ["ais", "adsb"], {"adsb": "69420"}) == {"adsb": 0} From 575a99feb571227b3546f5d68aa71bbf5c49ae23 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 14:41:27 -0400 Subject: [PATCH 2/2] checkin: report an unreachable secondary-sdr as null, never keep stale 'Running' QA blocker: when the secondary-sdr container was down, the checkin omitted secondary_sdr_running / sdr_devices / op25_sdr_serial, and C2 only overwrites keys that are present, so the dashboard kept the last 'Running' forever. Send explicit nulls. The local card also says 'not reported' rather than 'not plugged in' for a pin it can't check. Co-Authored-By: Claude Opus 5.5 --- drb-edge-node/app/internal/mqtt_manager.py | 6 ++++++ drb-edge-node/app/templates/index.html | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/drb-edge-node/app/internal/mqtt_manager.py b/drb-edge-node/app/internal/mqtt_manager.py index 73aba28..f7e3030 100644 --- a/drb-edge-node/app/internal/mqtt_manager.py +++ b/drb-edge-node/app/internal/mqtt_manager.py @@ -236,6 +236,12 @@ class MQTTManager: } payload["sdr_pins"] = config.sdr_pins secondary = await secondary_sdr_client.status() + # Unreachable secondary-sdr: say so with explicit nulls. C2 only + # overwrites keys present in the checkin, so omitting them would leave + # a dead decoder showing "Running" forever. + payload["secondary_sdr_running"] = None + payload["sdr_devices"] = None + payload["op25_sdr_serial"] = None if secondary is not None: payload["secondary_sdr_running"] = [r["mode"] for r in secondary.get("running", [])] if secondary.get("devices") is not None: diff --git a/drb-edge-node/app/templates/index.html b/drb-edge-node/app/templates/index.html index de7e04e..cfe08d5 100644 --- a/drb-edge-node/app/templates/index.html +++ b/drb-edge-node/app/templates/index.html @@ -568,7 +568,8 @@ return `` + devs.map(d => ``).join('') + - (selected && !known ? `` : ''); + (selected && !known ? `` : ''); } function renderSdr() { @@ -585,7 +586,7 @@ const running = sdr.running || []; sdrRows.forEach((row, i) => { const [name, hint] = SDR_LABELS[row.mode] || [row.mode, '']; - const pinMissing = row.pin && !(sdr.devices || []).some(d => d.serial === row.pin); + const pinMissing = row.pin && sdr.devices !== null && !sdr.devices.some(d => d.serial === row.pin); const state = !row.enabled ? 'Off' : sdrDirty ? 'Unsaved' : sdr.running === null ? 'Unknown'