diff --git a/.env.example b/.env.example index ec8456b..a46faf3 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,13 @@ OP25_TERMINAL_URL=http://localhost:8081 # for local development off a real node; leave false everywhere else. OP25_DEBUG_EXPOSE=false +# Secondary SDR container (node-26#9) — only matters if a second physical SDR +# is present and secondary_sdr_mode is set to adsb|ais via the edge dashboard +# or C2. Usually no need to change. +SECONDARY_SDR_API_URL=http://localhost:8002 +# Same caveat as OP25_DEBUG_EXPOSE — debugging aid only, leave false. +SECONDARY_SDR_DEBUG_EXPOSE=false + # --- Local dashboard / API login --------------------------------------------- # Protects the node's local dashboard (port 80) and JSON API. The node is # reachable by anyone on whatever site's LAN it's deployed to, so this MUST be diff --git a/docker-compose.yml b/docker-compose.yml index b47a795..3890e0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,21 @@ services: depends_on: - icecast + # Claims the node's SECOND physical SDR (op25 always claims the first). + # Only useful if secondary_sdr_mode is set to adsb|ais via the edge-node + # config; otherwise it just sits idle answering /secondary/status. See + # node-26#9. Same network/device access as op25 for the same reason: it + # needs the raw USB device, not a virtualized one. + secondary-sdr: + image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:latest + build: ./secondary-sdr-container + restart: unless-stopped + privileged: true + network_mode: host + env_file: .env + volumes: + - /dev:/dev + edge-node: image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest build: ./drb-edge-node diff --git a/drb-edge-node/app/config.py b/drb-edge-node/app/config.py index 1ac29e1..90c3a06 100644 --- a/drb-edge-node/app/config.py +++ b/drb-edge-node/app/config.py @@ -136,6 +136,9 @@ class Settings(BaseSettings): op25_api_url: str = "http://localhost:8001" op25_terminal_url: str = "http://localhost:8081" + # Secondary SDR container (node-26#9) — ADS-B / AIS on a second SDR + secondary_sdr_api_url: str = "http://localhost:8002" + # Paths (volume mounts) config_path: str = "/configs" recordings_path: str = "/recordings" diff --git a/drb-edge-node/app/internal/secondary_sdr_client.py b/drb-edge-node/app/internal/secondary_sdr_client.py new file mode 100644 index 0000000..402ce3b --- /dev/null +++ b/drb-edge-node/app/internal/secondary_sdr_client.py @@ -0,0 +1,59 @@ +import httpx +from typing import Any, Dict, Optional +from app.config import settings +from app.internal.logger import logger + + +class SecondarySdrClient: + """Talks to the secondary-sdr-container (node-26#9) over its control API. + + Mirrors op25_client.py's shape on purpose — same failure handling (log + and return None/False rather than raise), since this container is + optional and its absence must never break the primary op25 radio path. + """ + + def __init__(self): + self.api_url = settings.secondary_sdr_api_url + + async def start(self, mode: str) -> bool: + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post(f"{self.api_url}/secondary/start", json={"mode": mode}) + r.raise_for_status() + return True + except Exception as e: + logger.error(f"Secondary SDR start (mode={mode!r}) failed: {e}") + return False + + async def stop(self) -> bool: + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post(f"{self.api_url}/secondary/stop") + r.raise_for_status() + return True + except Exception as e: + logger.error(f"Secondary SDR stop failed: {e}") + return False + + async def status(self) -> Optional[Dict[str, Any]]: + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{self.api_url}/secondary/status") + r.raise_for_status() + return r.json() + except Exception as e: + logger.error(f"Secondary SDR status failed: {e}") + return None + + async def data(self) -> Optional[Dict[str, Any]]: + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{self.api_url}/secondary/data") + r.raise_for_status() + return r.json() + except Exception as e: + logger.error(f"Secondary SDR data fetch failed: {e}") + return None + + +secondary_sdr_client = SecondarySdrClient() diff --git a/drb-edge-node/app/internal/telemetry_uplink.py b/drb-edge-node/app/internal/telemetry_uplink.py new file mode 100644 index 0000000..646bcd7 --- /dev/null +++ b/drb-edge-node/app/internal/telemetry_uplink.py @@ -0,0 +1,41 @@ +import asyncio + +import httpx + +from app.config import settings +from app.internal import credentials +from app.internal.config_manager import load_node_config +from app.internal.logger import logger +from app.internal.secondary_sdr_client import secondary_sdr_client + +# How often the second-SDR decoder's current snapshot is forwarded to C2 +# (node-26#9). This is a live-map overlay, not a flight/vessel history, so +# there is no backlog/retry on a missed tick — the next one supersedes it. +UPLINK_INTERVAL_SECONDS = 10 + + +async def _post_snapshot(path: str, body: dict) -> None: + if not settings.c2_url: + return + api_key = credentials.get_api_key() + if not api_key: + return + headers = {"Authorization": f"Bearer {api_key}"} + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post(f"{settings.c2_url}{path}", json=body, headers=headers) + r.raise_for_status() + except Exception as e: + logger.debug(f"Telemetry uplink to {path} failed: {e}") + + +async def telemetry_uplink_loop(): + while True: + await asyncio.sleep(UPLINK_INTERVAL_SECONDS) + config = load_node_config() + if config.secondary_sdr_mode != "adsb": + continue + snapshot = await secondary_sdr_client.data() + if not snapshot or not snapshot.get("aircraft"): + continue + await _post_snapshot("/telemetry/adsb", {"aircraft": snapshot["aircraft"]}) diff --git a/drb-edge-node/app/main.py b/drb-edge-node/app/main.py index 8ca939c..e3837c8 100644 --- a/drb-edge-node/app/main.py +++ b/drb-edge-node/app/main.py @@ -260,6 +260,13 @@ async def on_config_push(payload: dict): await op25_client.start() logger.info(f"Config push applied: {config.name}") + if secondary_sdr_mode is not None: + from app.internal.secondary_sdr_client import secondary_sdr_client + if secondary_sdr_mode in ("adsb", "ais"): + await secondary_sdr_client.start(secondary_sdr_mode) + else: + await secondary_sdr_client.stop() + # --------------------------------------------------------------------------- # App lifecycle @@ -315,12 +322,20 @@ async def lifespan(app: FastAPI): logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…") await asyncio.sleep(3) + if node_cfg.secondary_sdr_mode in ("adsb", "ais"): + 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.") + await secondary_sdr_client.start(node_cfg.secondary_sdr_mode) + heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop()) + from app.internal.telemetry_uplink import telemetry_uplink_loop + telemetry_task = asyncio.create_task(telemetry_uplink_loop()) yield # --- app running --- logger.info("Edge node shutting down.") heartbeat_task.cancel() + telemetry_task.cancel() await metadata_watcher.stop() await call_recorder.stop() await radio_bot.stop() diff --git a/secondary-sdr-container/Dockerfile b/secondary-sdr-container/Dockerfile new file mode 100644 index 0000000..200ac33 --- /dev/null +++ b/secondary-sdr-container/Dockerfile @@ -0,0 +1,52 @@ +# Secondary-SDR Container — node-26#9 +# +# Claims the node's SECOND physical SDR (the first is always op25's). Mode is +# chosen at runtime via the control API, not baked in: dump1090 for ADS-B, +# AIS-catcher for AIS (op25_2 mode is not handled here yet — see node-26#9). +# +# Device claiming is by RTL-SDR index, not serial (op25's DeviceConfig.args +# has no serial concept either — see op25-container/app/models.py). Index 0 +# is reserved for op25; this container always addresses index 1. That's a +# real limitation once serial-stable device binding matters (hot-unplug / +# replug can swap indices) — tracked in node-26#9, not fixed here. +# +# UNVERIFIED: this image has not been built or run against real hardware in +# this session (sandboxed authoring machine, no docker). dump1090 and +# AIS-catcher's exact CLI flags below are believed correct from their +# published docs but not confirmed against a real capture — the CTO/QA +# review before this ships to a real node should build and smoke-test it. +FROM python:3.14-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + git build-essential cmake pkg-config \ + librtlsdr-dev libusb-1.0-0-dev libssl-dev zlib1g-dev usbutils + +# dump1090 (antirez/classic) — ADS-B decoder. --write-json support is a +# long-standing, well-documented feature of this fork. +RUN git clone https://github.com/antirez/dump1090 /opt/dump1090 && \ + cd /opt/dump1090 && make + +# AIS-catcher — AIS decoder. +RUN git clone https://github.com/jvde-github/AIS-catcher /opt/AIS-catcher && \ + cd /opt/AIS-catcher && mkdir build && cd build && cmake .. && make + +EXPOSE 8002 + +VOLUME ["/configs"] + +WORKDIR /app +COPY ./app /app + +COPY docker-entrypoint.sh /usr/local/bin/ +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \ + chmod +x /usr/local/bin/docker-entrypoint.sh + +COPY requirements.txt /tmp/requirements.txt +RUN pip3 install --no-cache-dir -r /tmp/requirements.txt + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["python", "main.py"] diff --git a/secondary-sdr-container/app/config.py b/secondary-sdr-container/app/config.py new file mode 100644 index 0000000..9b7e383 --- /dev/null +++ b/secondary-sdr-container/app/config.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Same rationale as op25-container's OP25_DEBUG_EXPOSE: both containers + # share the host network namespace (network_mode: host), so edge-node + # reaches this control API over localhost regardless of this flag. False + # (default) binds 127.0.0.1; true exposes unauthenticated start/stop to + # the node's LAN and should only ever be set for local development. + secondary_sdr_debug_expose: bool = False + + class Config: + env_file = ".env" + + +settings = Settings() + + +def bind_host() -> str: + return "0.0.0.0" if settings.secondary_sdr_debug_expose else "127.0.0.1" diff --git a/secondary-sdr-container/app/internal/decoder_control.py b/secondary-sdr-container/app/internal/decoder_control.py new file mode 100644 index 0000000..6e96a93 --- /dev/null +++ b/secondary-sdr-container/app/internal/decoder_control.py @@ -0,0 +1,147 @@ +import json +import os +import signal +import subprocess +from pathlib import Path +from typing import Any, Dict, List, Optional + +from internal.logger import create_logger + +LOGGER = create_logger(__name__) + +# The node's SECOND SDR, addressed by RTL-SDR index — not serial. op25 always +# claims index 0 (its DeviceConfig.args has no serial concept either, see +# op25-container/app/models.py). No hot-plug re-detection: if the two +# dongles' USB enumeration order changes, this claims the wrong one. Tracked +# as a real gap in node-26#9, not fixed here. +SECONDARY_SDR_DEVICE_INDEX = 1 + +_PGID_FILE = "/tmp/secondary_sdr.pgid" +_MODE_FILE = "/tmp/secondary_sdr.mode" +ADSB_JSON_DIR = Path("/tmp/adsb") + + +def _save_state(pgid: int, mode: str) -> None: + Path(_PGID_FILE).write_text(str(pgid)) + Path(_MODE_FILE).write_text(mode) + + +def _read_pgid() -> Optional[int]: + try: + return int(Path(_PGID_FILE).read_text().strip()) + except Exception: + return None + + +def _read_mode() -> Optional[str]: + try: + return Path(_MODE_FILE).read_text().strip() + except Exception: + return None + + +def is_running() -> bool: + pgid = _read_pgid() + if pgid is None: + return False + try: + os.killpg(pgid, 0) + return True + except OSError: + return False + + +def _adsb_command() -> List[str]: + ADSB_JSON_DIR.mkdir(parents=True, exist_ok=True) + return [ + "/opt/dump1090/dump1090", + "--net", + "--device-index", str(SECONDARY_SDR_DEVICE_INDEX), + "--write-json", str(ADSB_JSON_DIR), + "--write-json-every", "1", + ] + + +def start(mode: str) -> bool: + if is_running(): + stop() + + if mode == "adsb": + cmd = _adsb_command() + elif mode == "ais": + raise ValueError("AIS mode is not wired yet (node-26#9) — only 'adsb' runs today.") + else: + raise ValueError(f"Unknown secondary SDR mode: {mode!r}") + + try: + proc = subprocess.Popen(cmd, preexec_fn=os.setsid) + _save_state(proc.pid, mode) + LOGGER.info(f"Started secondary SDR decoder mode={mode!r} pid={proc.pid}") + return True + except Exception as e: + LOGGER.error(f"Failed to start secondary SDR decoder mode={mode!r}: {e}") + return False + + +def stop() -> bool: + pgid = _read_pgid() + if pgid is None: + return True + try: + os.killpg(pgid, signal.SIGTERM) + except OSError: + pass + try: + os.remove(_PGID_FILE) + except OSError: + pass + try: + os.remove(_MODE_FILE) + except OSError: + pass + return True + + +def status() -> Dict[str, Any]: + running = is_running() + return { + "status": "running" if running else "stopped", + "mode": _read_mode() if running else None, + } + + +def _read_adsb_snapshot() -> List[Dict[str, Any]]: + """ + Map dump1090's aircraft.json (--write-json output) to the server's + telemetry schema. Field names (hex/flight/lat/lon/altitude/speed/track) + match dump1090's long-documented JSON format — UNVERIFIED against a real + capture in this session, see the Dockerfile's caveat. + """ + path = ADSB_JSON_DIR / "aircraft.json" + try: + raw = json.loads(path.read_text()) + except Exception: + return [] + + out = [] + for a in raw.get("aircraft", []): + icao = a.get("hex") + if not icao: + continue + out.append({ + "icao": icao.upper(), + "callsign": (a.get("flight") or "").strip() or None, + "lat": a.get("lat"), + "lon": a.get("lon"), + "altitude_ft": a.get("altitude"), + "ground_speed_kt": a.get("speed"), + "track_deg": a.get("track"), + }) + return out + + +def data() -> Dict[str, Any]: + mode = _read_mode() + if mode == "adsb": + return {"mode": mode, "aircraft": _read_adsb_snapshot()} + return {"mode": mode, "aircraft": []} diff --git a/secondary-sdr-container/app/internal/logger.py b/secondary-sdr-container/app/internal/logger.py new file mode 100644 index 0000000..1c6df81 --- /dev/null +++ b/secondary-sdr-container/app/internal/logger.py @@ -0,0 +1,31 @@ +import logging +from logging.handlers import RotatingFileHandler + + +def create_logger(name, level=logging.DEBUG, max_bytes=10485760, backup_count=2): + debug_log_file = "./secondary-sdr.debug.log" + info_log_file = "./secondary-sdr.log" + + logger = logging.getLogger(name) + logger.setLevel(level) + + if not logger.hasHandlers(): + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + + debug_file_handler = RotatingFileHandler(debug_log_file, maxBytes=max_bytes, backupCount=backup_count) + debug_file_handler.setLevel(logging.DEBUG) + + info_file_handler = RotatingFileHandler(info_log_file, maxBytes=max_bytes, backupCount=backup_count) + info_file_handler.setLevel(logging.INFO) + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + console_handler.setFormatter(formatter) + debug_file_handler.setFormatter(formatter) + info_file_handler.setFormatter(formatter) + + logger.addHandler(console_handler) + logger.addHandler(debug_file_handler) + logger.addHandler(info_file_handler) + + return logger diff --git a/secondary-sdr-container/app/main.py b/secondary-sdr-container/app/main.py new file mode 100644 index 0000000..e47560d --- /dev/null +++ b/secondary-sdr-container/app/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI +import routers.secondary_controller as secondary_controller +from config import bind_host + +app = FastAPI() + +app.include_router(secondary_controller.create_secondary_router(), prefix="/secondary") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host=bind_host(), port=8002, reload=True) diff --git a/secondary-sdr-container/app/routers/secondary_controller.py b/secondary-sdr-container/app/routers/secondary_controller.py new file mode 100644 index 0000000..31492f6 --- /dev/null +++ b/secondary-sdr-container/app/routers/secondary_controller.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from internal import decoder_control +from internal.logger import create_logger + +LOGGER = create_logger(__name__) + + +class StartBody(BaseModel): + mode: str # adsb | ais + + +def create_secondary_router(): + router = APIRouter() + + @router.post("/start") + async def start(body: StartBody): + try: + ok = decoder_control.start(body.mode) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + if not ok: + raise HTTPException(status_code=500, detail="Failed to start secondary SDR decoder") + return {"status": f"secondary SDR started ({body.mode})"} + + @router.post("/stop") + async def stop(): + decoder_control.stop() + return {"status": "secondary SDR stopped"} + + @router.get("/status") + async def get_status(): + return decoder_control.status() + + @router.get("/data") + async def get_data(): + return decoder_control.data() + + return router diff --git a/secondary-sdr-container/docker-entrypoint.sh b/secondary-sdr-container/docker-entrypoint.sh new file mode 100644 index 0000000..5d260ed --- /dev/null +++ b/secondary-sdr-container/docker-entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/bash +mkdir -p /tmp/adsb +exec "$@" diff --git a/secondary-sdr-container/requirements.txt b/secondary-sdr-container/requirements.txt new file mode 100644 index 0000000..74881f1 --- /dev/null +++ b/secondary-sdr-container/requirements.txt @@ -0,0 +1,3 @@ +uvicorn +fastapi +pydantic-settings