From 8f5fca87571c7ff37ebe7a998096d263d29f1f7e Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 20 Sep 2026 15:56:11 -0400 Subject: [PATCH] Add second-SDR plumbing: secondary_sdr_mode config + hardware reporting Adds a per-node secondary_sdr_mode config field (none|adsb|ais|op25_2), applied the same way as hardware_preset/ppm_override so it survives system reassignment. op25-container gets a GET /devices endpoint that counts connected SDRs via lsusb; the edge-node checkin now reports sdr_count and secondary_sdr_mode up to the server, the first node-initiated hardware report (everything else was C2 pushing config down). Tracked as node-26#9. Co-Authored-By: Claude Sonnet 5 --- drb-edge-node/app/internal/mqtt_manager.py | 7 +++++++ drb-edge-node/app/internal/op25_client.py | 10 ++++++++++ drb-edge-node/app/main.py | 3 +++ drb-edge-node/app/models.py | 1 + op25-container/Dockerfile | 2 +- op25-container/app/routers/op25_controller.py | 19 +++++++++++++++++++ 6 files changed, 41 insertions(+), 1 deletion(-) diff --git a/drb-edge-node/app/internal/mqtt_manager.py b/drb-edge-node/app/internal/mqtt_manager.py index 8b0f1c0..3e12b7a 100644 --- a/drb-edge-node/app/internal/mqtt_manager.py +++ b/drb-edge-node/app/internal/mqtt_manager.py @@ -213,7 +213,9 @@ class MQTTManager: async def _publish_checkin(self): from app.internal.discord_radio import radio_bot from app.internal.config_manager import load_node_config + from app.internal.op25_client import op25_client config = load_node_config() + devices = await op25_client.devices() payload = { "node_id": settings.node_id, "name": settings.node_name, @@ -225,7 +227,12 @@ class MQTTManager: "is_overridden": config.override_system_id is not None and config.node_type != "portable", "override_system_id": config.override_system_id, "enforce_override_timeout": config.enforce_override_timeout, + "secondary_sdr_mode": config.secondary_sdr_mode, } + # Best-effort — the first node-initiated hardware-report field. Omit + # rather than guess if op25's control API is unreachable. + if devices is not None: + payload["sdr_count"] = devices.get("count") self._publish(self._t_checkin, payload, qos=1) def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False): diff --git a/drb-edge-node/app/internal/op25_client.py b/drb-edge-node/app/internal/op25_client.py index 68f7026..a1cc0c7 100644 --- a/drb-edge-node/app/internal/op25_client.py +++ b/drb-edge-node/app/internal/op25_client.py @@ -65,6 +65,16 @@ class OP25Client: logger.error(f"OP25 status failed: {e}") return None + async def devices(self) -> Optional[Dict[str, Any]]: + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{self.api_url}/op25/devices") + r.raise_for_status() + return r.json() + except Exception as e: + logger.error(f"OP25 device enumeration failed: {e}") + return None + async def generate_config(self, config: Dict[str, Any]) -> bool: try: async with httpx.AsyncClient(timeout=10) as client: diff --git a/drb-edge-node/app/main.py b/drb-edge-node/app/main.py index 5616aff..8ca939c 100644 --- a/drb-edge-node/app/main.py +++ b/drb-edge-node/app/main.py @@ -224,6 +224,7 @@ async def on_config_push(payload: dict): hardware_preset = payload.pop("hardware_preset", None) ppm_override = payload.pop("ppm_override", None) node_type = payload.pop("node_type", None) + secondary_sdr_mode = payload.pop("secondary_sdr_mode", None) enforce_override_timeout = payload.pop("enforce_override_timeout", None) try: config = SystemConfig(**payload) @@ -243,6 +244,8 @@ async def on_config_push(payload: dict): node_cfg.ppm_override = float(ppm_override) if node_type is not None: 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: node_cfg.enforce_override_timeout = bool(enforce_override_timeout) save_node_config(node_cfg) diff --git a/drb-edge-node/app/models.py b/drb-edge-node/app/models.py index 441efa7..8cebf61 100644 --- a/drb-edge-node/app/models.py +++ b/drb-edge-node/app/models.py @@ -34,6 +34,7 @@ class NodeConfig(BaseModel): hardware_preset: str = "rtl-sdr-v3" ppm_override: Optional[float] = None node_type: str = "fixed" # fixed or portable + secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR enforce_override_timeout: bool = True override_system_id: Optional[str] = None override_config: Optional[SystemConfig] = None diff --git a/op25-container/Dockerfile b/op25-container/Dockerfile index ec22fdc..12a5b6d 100644 --- a/op25-container/Dockerfile +++ b/op25-container/Dockerfile @@ -12,7 +12,7 @@ ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies RUN apt-get update && \ apt-get upgrade -y && \ - apt-get install git pulseaudio pulseaudio-utils liquidsoap -y + apt-get install git pulseaudio pulseaudio-utils liquidsoap usbutils -y # Install custom PulseAudio system config (enables anonymous access for edge-node) COPY system.pa /etc/pulse/system.pa diff --git a/op25-container/app/routers/op25_controller.py b/op25-container/app/routers/op25_controller.py index c236570..ea496e0 100644 --- a/op25-container/app/routers/op25_controller.py +++ b/op25-container/app/routers/op25_controller.py @@ -1,6 +1,7 @@ from fastapi import HTTPException, APIRouter import subprocess import os +import re import signal import json from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS @@ -69,6 +70,24 @@ def create_op25_router(): async def get_status(): return {"status": "running" if _is_running() else "stopped"} + @router.get("/devices") + async def list_sdr_devices(): + """Enumerate connected SDR-looking USB devices via lsusb. + + Same match heuristic as install.sh's one-shot host check — good enough + to answer "is a second SDR plugged in", not a serial-level device + binding (op25's DeviceConfig.args has no serial concept yet either). + """ + devices = [] + try: + out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout + for line in out.splitlines(): + if re.search(r"rtl2838|realtek.*283[28]|sdr", line, re.IGNORECASE): + devices.append(line.strip()) + except Exception as e: + LOGGER.warning(f"SDR device enumeration failed: {e}") + return {"count": len(devices), "devices": devices} + @router.post("/generate-config") async def generate_config(generator: ConfigGenerator): try: