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=<serial>: 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3ecc7eff1a
commit
9b50ba8114
@@ -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
|
||||
Reference in New Issue
Block a user