Second SDR: ADS-B (dump1090) / AIS (AIS-catcher) support #10

Open
logan wants to merge 3 commits from feat/second-sdr-adsb-ais into main
6 changed files with 41 additions and 1 deletions
Showing only changes of commit 8f5fca8757 - Show all commits
@@ -213,7 +213,9 @@ class MQTTManager:
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
config = load_node_config() config = load_node_config()
devices = await op25_client.devices()
payload = { payload = {
"node_id": settings.node_id, "node_id": settings.node_id,
"name": settings.node_name, "name": settings.node_name,
@@ -225,7 +227,12 @@ class MQTTManager:
"is_overridden": config.override_system_id is not None and config.node_type != "portable", "is_overridden": config.override_system_id is not None and config.node_type != "portable",
"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,
} }
# 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) 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):
+10
View File
@@ -65,6 +65,16 @@ class OP25Client:
logger.error(f"OP25 status failed: {e}") logger.error(f"OP25 status failed: {e}")
return None 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: async def generate_config(self, config: Dict[str, Any]) -> bool:
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
+3
View File
@@ -224,6 +224,7 @@ 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)
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)
@@ -243,6 +244,8 @@ 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)
+1
View File
@@ -34,6 +34,7 @@ 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
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
+1 -1
View File
@@ -12,7 +12,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies # Install system dependencies
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y && \ 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) # Install custom PulseAudio system config (enables anonymous access for edge-node)
COPY system.pa /etc/pulse/system.pa COPY system.pa /etc/pulse/system.pa
@@ -1,6 +1,7 @@
from fastapi import HTTPException, APIRouter from fastapi import HTTPException, APIRouter
import subprocess import subprocess
import os import os
import re
import signal import signal
import json import json
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS 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(): async def get_status():
return {"status": "running" if _is_running() else "stopped"} 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") @router.post("/generate-config")
async def generate_config(generator: ConfigGenerator): async def generate_config(generator: ConfigGenerator):
try: try: