diff --git a/.gitignore b/.gitignore index a574434..3b5a9c6 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,17 @@ data/radiosonde/ # SDR capture files (large IQ recordings) data/subghz/captures/ +# Kismet capture files +*.kismet +*.kismet.json + +# ADS-B aircraft database (downloaded at runtime) +aircraft_db.json +aircraft_db_meta.json + +# SoapySDR build directory (compiled during setup, contains its own .git) +SoapySDR/ + # Env files .env .env.* diff --git a/CHANGELOG.md b/CHANGELOG.md index fb756f1..d480dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to iNTERCEPT will be documented in this file. +## [2.30.0] - 2026-07-07 + +### Added +- **APRS station export** — `GET /aprs/export?format=json|csv` downloads all currently tracked APRS stations, consistent with the existing WiFi/Bluetooth/AIS export pattern. +- **USRP support** — Ettus USRP devices (N200, B200, B210) are now detected and usable via the SoapyUHD bridge. FM demod, ADS-B, ISM, AIS, and I/Q capture all supported through the existing SoapySDR toolchain. +- **MQTT data export** — Optional MQTT publisher that broadcasts decoded events from every module to a configurable broker. Disabled by default; set `INTERCEPT_MQTT_BROKER` to enable. Topics follow the pattern `//` (e.g. `intercept/aprs/packet`). Configure with `INTERCEPT_MQTT_PORT`, `INTERCEPT_MQTT_USER`, `INTERCEPT_MQTT_PASSWORD`, `INTERCEPT_MQTT_TOPIC_PREFIX`, `INTERCEPT_MQTT_RETAIN`. + +--- + ## [2.29.0] - 2026-07-05 ### Added diff --git a/config.py b/config.py index 9fede75..b12ce53 100644 --- a/config.py +++ b/config.py @@ -7,10 +7,19 @@ import os import sys # Application version -VERSION = "2.29.0" +VERSION = "2.30.0" # Changelog - latest release notes (shown on welcome screen) CHANGELOG = [ + { + "version": "2.30.0", + "date": "July 2026", + "highlights": [ + "Feat: APRS station export — download all tracked stations as JSON or CSV via /aprs/export", + "Feat: USRP support — Ettus USRP N200/B200/B210 detected and usable via SoapyUHD bridge", + "Feat: MQTT data export — publish decoded events from all modules to a configurable broker (set INTERCEPT_MQTT_BROKER to enable)", + ], + }, { "version": "2.29.0", "date": "July 2026", @@ -534,6 +543,14 @@ ALERT_WEBHOOK_URL = _get_env("ALERT_WEBHOOK_URL", "") ALERT_WEBHOOK_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "") ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5) +# MQTT data export (optional — disabled when MQTT_BROKER is empty) +MQTT_BROKER = _get_env("MQTT_BROKER", "") +MQTT_PORT = _get_env_int("MQTT_PORT", 1883) +MQTT_USER = _get_env("MQTT_USER", "") +MQTT_PASSWORD = _get_env("MQTT_PASSWORD", "") +MQTT_TOPIC_PREFIX = _get_env("MQTT_TOPIC_PREFIX", "intercept") +MQTT_RETAIN = _get_env_bool("MQTT_RETAIN", False) + # Admin credentials ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin") ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin") diff --git a/pyproject.toml b/pyproject.toml index 3b4e265..3b744fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "intercept" -version = "2.27.0" +version = "2.30.0" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" readme = "README.md" requires-python = ">=3.9" diff --git a/requirements.txt b/requirements.txt index 3ba9792..44d0731 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,6 +53,9 @@ websocket-client>=1.6.0 # System health monitoring (optional - graceful fallback if unavailable) psutil>=5.9.0 +# MQTT data export (optional - only needed if INTERCEPT_MQTT_BROKER is set) +paho-mqtt>=1.6.0 + # Production WSGI server (optional - falls back to Flask dev server) gunicorn>=21.2.0 gevent>=23.9.0 diff --git a/routes/aprs.py b/routes/aprs.py index 3164ed9..5290515 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -1653,6 +1653,48 @@ def get_stations() -> Response: return jsonify({"stations": list(aprs_stations.values()), "count": len(aprs_stations)}) +@aprs_bp.route("/export") +def export_data() -> Response: + """Export tracked APRS stations as JSON or CSV. + + Query params: + format: 'json' or 'csv' (default: json) + """ + export_format = request.args.get("format", "json").lower() + stations = list(aprs_stations.values()) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if export_format == "csv": + import io + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["Callsign", "Latitude", "Longitude", "Symbol", "Packet Type", "Last Seen"]) + for s in stations: + writer.writerow( + [ + s.get("callsign", ""), + s.get("lat", ""), + s.get("lon", ""), + s.get("symbol", ""), + s.get("packet_type", ""), + s.get("last_seen", ""), + ] + ) + response = Response(output.getvalue(), mimetype="text/csv") + response.headers["Content-Disposition"] = f"attachment; filename=aprs_stations_{timestamp}.csv" + return response + + data = { + "stations": stations, + "count": len(stations), + "exported_at": datetime.now().isoformat(), + } + response = Response(json.dumps(data, indent=2), mimetype="application/json") + response.headers["Content-Disposition"] = f"attachment; filename=aprs_stations_{timestamp}.json" + return response + + @aprs_bp.route("/data") def aprs_data() -> Response: """Get APRS data snapshot for remote controller polling compatibility.""" diff --git a/utils/event_pipeline.py b/utils/event_pipeline.py index 2bf9b62..ea2090b 100644 --- a/utils/event_pipeline.py +++ b/utils/event_pipeline.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from utils.alerts import get_alert_manager +from utils.mqtt import publish as mqtt_publish from utils.recording import get_recording_manager from utils.temporal_patterns import get_pattern_detector @@ -54,6 +55,12 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) - # Alert failures should never break streaming pass + try: + mqtt_publish(mode, event, event_type) + except Exception: + # MQTT failures should never break streaming + pass + def _extract_device_id(event: dict) -> str | None: for field in DEVICE_ID_FIELDS: diff --git a/utils/mqtt.py b/utils/mqtt.py new file mode 100644 index 0000000..2383f35 --- /dev/null +++ b/utils/mqtt.py @@ -0,0 +1,107 @@ +""" +Optional MQTT data export. + +Publishes decoded events from all modules to a configurable MQTT broker. +Disabled when INTERCEPT_MQTT_BROKER is not set — no broker, no connection, +no error. + +Topics follow the pattern: + // + +Payload is JSON, matching the SSE event structure already used by the +frontend. Configure via environment variables: + + INTERCEPT_MQTT_BROKER broker hostname / IP (required to enable) + INTERCEPT_MQTT_PORT port (default 1883) + INTERCEPT_MQTT_USER username (optional) + INTERCEPT_MQTT_PASSWORD password (optional) + INTERCEPT_MQTT_TOPIC_PREFIX topic prefix (default "intercept") + INTERCEPT_MQTT_RETAIN retain flag (default false) +""" + +from __future__ import annotations + +import json +import logging +import threading +from typing import Any + +logger = logging.getLogger(__name__) + +_client = None +_lock = threading.Lock() +_enabled: bool | None = None # None = not yet initialised + + +def _get_config(): + import config + + return config + + +def _is_enabled() -> bool: + global _enabled + if _enabled is None: + cfg = _get_config() + _enabled = bool(cfg.MQTT_BROKER) + return _enabled + + +def _get_client(): + """Return a connected paho MQTT client, creating it on first call.""" + global _client + if _client is not None: + return _client + + with _lock: + if _client is not None: + return _client + + try: + import paho.mqtt.client as mqtt + except ImportError: + logger.warning("paho-mqtt not installed; MQTT export disabled. Install with: pip install paho-mqtt") + return None + + cfg = _get_config() + + client = mqtt.Client(client_id="intercept", clean_session=True) + if cfg.MQTT_USER: + client.username_pw_set(cfg.MQTT_USER, cfg.MQTT_PASSWORD or None) + + try: + client.connect(cfg.MQTT_BROKER, cfg.MQTT_PORT, keepalive=60) + client.loop_start() + logger.info("MQTT connected to %s:%d (prefix=%s)", cfg.MQTT_BROKER, cfg.MQTT_PORT, cfg.MQTT_TOPIC_PREFIX) + _client = client + except Exception as exc: + logger.warning("MQTT connect failed: %s — export disabled until restart", exc) + return None + + return _client + + +def publish(mode: str, event: dict[str, Any], event_type: str | None = None) -> None: + """Publish a decoded event to the MQTT broker. + + Args: + mode: Source module name (e.g. 'aprs', 'adsb', 'pager'). + event: The decoded event dict. + event_type: Optional sub-type string (e.g. 'packet', 'aircraft'). + """ + if not _is_enabled(): + return + + client = _get_client() + if client is None: + return + + cfg = _get_config() + subtopic = event_type or mode + topic = f"{cfg.MQTT_TOPIC_PREFIX}/{mode}/{subtopic}" + + try: + payload = json.dumps(event, default=str) + client.publish(topic, payload, retain=cfg.MQTT_RETAIN) + except Exception as exc: + logger.debug("MQTT publish error on %s: %s", topic, exc) diff --git a/utils/sdr/__init__.py b/utils/sdr/__init__.py index 30118c6..20ef0eb 100644 --- a/utils/sdr/__init__.py +++ b/utils/sdr/__init__.py @@ -32,6 +32,7 @@ from .hackrf import HackRFCommandBuilder from .limesdr import LimeSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder from .sdrplay import SDRPlayCommandBuilder +from .usrp import USRPCommandBuilder from .validation import ( SDRValidationError, get_capabilities_for_type, @@ -53,6 +54,7 @@ class SDRFactory: SDRType.HACKRF: HackRFCommandBuilder, SDRType.AIRSPY: AirspyCommandBuilder, SDRType.SDRPLAY: SDRPlayCommandBuilder, + SDRType.USRP: USRPCommandBuilder, } @classmethod @@ -211,6 +213,7 @@ __all__ = [ "HackRFCommandBuilder", "AirspyCommandBuilder", "SDRPlayCommandBuilder", + "USRPCommandBuilder", # Validation "SDRValidationError", "validate_frequency", diff --git a/utils/sdr/base.py b/utils/sdr/base.py index 69ec4d5..4b4236b 100644 --- a/utils/sdr/base.py +++ b/utils/sdr/base.py @@ -20,8 +20,8 @@ class SDRType(Enum): HACKRF = "hackrf" AIRSPY = "airspy" SDRPLAY = "sdrplay" + USRP = "usrp" # Future support - # USRP = "usrp" # BLADE_RF = "bladerf" diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index 9e1007b..e6f4c5e 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -53,6 +53,7 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities: from .limesdr import LimeSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder from .sdrplay import SDRPlayCommandBuilder + from .usrp import USRPCommandBuilder builders = { SDRType.RTL_SDR: RTLSDRCommandBuilder, @@ -60,6 +61,7 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities: SDRType.HACKRF: HackRFCommandBuilder, SDRType.AIRSPY: AirspyCommandBuilder, SDRType.SDRPLAY: SDRPlayCommandBuilder, + SDRType.USRP: USRPCommandBuilder, } builder_class = builders.get(sdr_type) @@ -90,8 +92,8 @@ def _driver_to_sdr_type(driver: str) -> SDRType | None: "airspy": SDRType.AIRSPY, "airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder "sdrplay": SDRType.SDRPLAY, + "uhd": SDRType.USRP, # Future support - # 'uhd': SDRType.USRP, # 'bladerf': SDRType.BLADE_RF, } return mapping.get(driver.lower()) diff --git a/utils/sdr/usrp.py b/utils/sdr/usrp.py new file mode 100644 index 0000000..2863a1a --- /dev/null +++ b/utils/sdr/usrp.py @@ -0,0 +1,153 @@ +""" +USRP command builder implementation. + +Uses SoapySDR (via the UHD driver / SoapyUHD bridge) for all signal +processing tasks. The same rx_fm / rx_sdr / rtl_433 / AIS-catcher +toolchain used by HackRF applies here because they all speak SoapySDR. + +Tested target: USRP N200 / B200 / B210 (UHD driver, SoapyUHD bridge). +Requires: uhd, SoapySDR, SoapyUHD installed on the host or in Docker. +""" + +from __future__ import annotations + +from utils.dependencies import get_tool_path + +from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType + + +class USRPCommandBuilder(CommandBuilder): + """USRP command builder using SoapySDR / UHD.""" + + CAPABILITIES = SDRCapabilities( + sdr_type=SDRType.USRP, + freq_min_mhz=1.0, + freq_max_mhz=6000.0, + gain_min=0.0, + gain_max=76.0, # typical UHD normalised gain range + sample_rates=[1000000, 2000000, 4000000, 8000000, 16000000, 25000000], + supports_bias_t=False, + supports_ppm=False, + tx_capable=True, + supports_iq_capture=True, + ) + + def _build_device_string(self, device: SDRDevice) -> str: + """Build SoapySDR device string for USRP.""" + if device.serial and device.serial not in ("N/A", "Unknown"): + return f"driver=uhd,serial={device.serial}" + return "driver=uhd" + + def build_fm_demod_command( + self, + device: SDRDevice, + frequency_mhz: float, + sample_rate: int = 22050, + gain: float | None = None, + ppm: int | None = None, + modulation: str = "fm", + squelch: int | None = None, + bias_t: bool = False, + ) -> list[str]: + device_str = self._build_device_string(device) + rx_fm_path = get_tool_path("rx_fm") or "rx_fm" + cmd = [ + rx_fm_path, + "-d", + device_str, + "-f", + f"{frequency_mhz}M", + "-M", + modulation, + "-s", + str(sample_rate), + ] + if gain is not None and gain > 0: + cmd.extend(["-g", str(int(gain))]) + if squelch is not None and squelch > 0: + cmd.extend(["-l", str(squelch)]) + cmd.append("-") + return cmd + + def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]: + device_str = self._build_device_string(device) + cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"] + if gain is not None: + cmd.extend(["--gain", str(int(gain))]) + return cmd + + def build_ism_command( + self, + device: SDRDevice, + frequency_mhz: float = 433.92, + gain: float | None = None, + ppm: int | None = None, + bias_t: bool = False, + ) -> list[str]: + device_str = self._build_device_string(device) + cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"] + if gain is not None and gain > 0: + cmd.extend(["-g", str(int(gain))]) + return cmd + + def build_ais_command( + self, + device: SDRDevice, + gain: float | None = None, + bias_t: bool = False, + tcp_port: int = 10110, + udp_host: str | None = None, + udp_port: int | None = None, + ) -> list[str]: + device_str = self._build_device_string(device) + cmd = [ + "AIS-catcher", + "-d", + f"soapysdr -d {device_str}", + "-S", + str(tcp_port), + "-o", + "5", + "-q", + ] + if gain is not None and gain > 0: + cmd.extend(["-gr", "tuner", str(int(gain))]) + if udp_host and udp_port: + cmd.extend(["-u", udp_host, str(udp_port)]) + return cmd + + def build_iq_capture_command( + self, + device: SDRDevice, + frequency_mhz: float, + sample_rate: int = 2048000, + gain: float | None = None, + ppm: int | None = None, + bias_t: bool = False, + output_format: str = "cu8", + ) -> list[str]: + device_str = self._build_device_string(device) + freq_hz = int(frequency_mhz * 1e6) + rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr" + cmd = [ + rx_sdr_path, + "-d", + device_str, + "-f", + str(freq_hz), + "-s", + str(sample_rate), + "-F", + "CU8", + ] + if gain is not None and gain > 0: + cmd.extend(["-g", str(int(gain))]) + cmd.append("-") + return cmd + + def get_capabilities(self) -> SDRCapabilities: + return self.CAPABILITIES + + @classmethod + def get_sdr_type(cls) -> SDRType: + return SDRType.USRP