Compare commits

..

2 Commits

Author SHA1 Message Date
James Smith dfab714104 feat: APRS export, USRP support, and MQTT data export (closes #222)
- GET /aprs/export?format=json|csv downloads all tracked APRS stations
- USRP (Ettus N200/B200/B210) detected via SoapyUHD and supported across
  all signal modes (FM demod, ADS-B, ISM, AIS, I/Q capture)
- Optional MQTT publisher broadcasts decoded events from every module;
  enabled by setting INTERCEPT_MQTT_BROKER, disabled by default
- paho-mqtt added to requirements.txt (optional dep)
- .gitignore: add *.kismet, aircraft_db.json/meta, SoapySDR/
- Bump version to 2.30.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:47:21 +01:00
James Smith 6d0b0437b3 fix: hide feed column in sensor dashboard mode, not just #output
The feed column (.pdir-feed-col) was left as a flex sibling with flex:1
while only its child #output was hidden, causing the sensor dashboard to
occupy ~50% width. Hide/restore the column itself in applyViewState().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 10:55:39 +01:00
13 changed files with 366 additions and 9 deletions
+11
View File
@@ -60,6 +60,17 @@ data/radiosonde/
# SDR capture files (large IQ recordings) # SDR capture files (large IQ recordings)
data/subghz/captures/ 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 files
.env .env
.env.* .env.*
+9
View File
@@ -2,6 +2,15 @@
All notable changes to iNTERCEPT will be documented in this file. 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 `<prefix>/<module>/<event_type>` (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 ## [2.29.0] - 2026-07-05
### Added ### Added
+18 -1
View File
@@ -7,10 +7,19 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.29.0" VERSION = "2.30.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ 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", "version": "2.29.0",
"date": "July 2026", "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_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "")
ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5) 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 credentials
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin") ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin") ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.27.0" version = "2.30.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+3
View File
@@ -53,6 +53,9 @@ websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable) # System health monitoring (optional - graceful fallback if unavailable)
psutil>=5.9.0 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) # Production WSGI server (optional - falls back to Flask dev server)
gunicorn>=21.2.0 gunicorn>=21.2.0
gevent>=23.9.0 gevent>=23.9.0
+42
View File
@@ -1653,6 +1653,48 @@ def get_stations() -> Response:
return jsonify({"stations": list(aprs_stations.values()), "count": len(aprs_stations)}) 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") @aprs_bp.route("/data")
def aprs_data() -> Response: def aprs_data() -> Response:
"""Get APRS data snapshot for remote controller polling compatibility.""" """Get APRS data snapshot for remote controller polling compatibility."""
+3
View File
@@ -164,15 +164,18 @@ const SensorDashboard = (function () {
function applyViewState(mode) { function applyViewState(mode) {
const view = document.getElementById('sensorDashboardView'); const view = document.getElementById('sensorDashboardView');
const output = document.getElementById('output'); const output = document.getElementById('output');
const feedCol = document.querySelector('.pdir-feed-col');
if (mode === 'sensor') { if (mode === 'sensor') {
const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard'; const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
const isDash = saved === 'dashboard'; const isDash = saved === 'dashboard';
if (view) view.style.display = isDash ? 'block' : 'none'; if (view) view.style.display = isDash ? 'block' : 'none';
if (output) output.style.display = isDash ? 'none' : ''; if (output) output.style.display = isDash ? 'none' : '';
if (feedCol) feedCol.style.display = isDash ? 'none' : '';
_updateToggle(isDash); _updateToggle(isDash);
} else { } else {
if (view) view.style.display = 'none'; if (view) view.style.display = 'none';
if (feedCol) feedCol.style.display = '';
} }
} }
+7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from utils.alerts import get_alert_manager from utils.alerts import get_alert_manager
from utils.mqtt import publish as mqtt_publish
from utils.recording import get_recording_manager from utils.recording import get_recording_manager
from utils.temporal_patterns import get_pattern_detector 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 # Alert failures should never break streaming
pass 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: def _extract_device_id(event: dict) -> str | None:
for field in DEVICE_ID_FIELDS: for field in DEVICE_ID_FIELDS:
+107
View File
@@ -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:
<prefix>/<module>/<event_type>
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)
+3
View File
@@ -32,6 +32,7 @@ from .hackrf import HackRFCommandBuilder
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder from .sdrplay import SDRPlayCommandBuilder
from .usrp import USRPCommandBuilder
from .validation import ( from .validation import (
SDRValidationError, SDRValidationError,
get_capabilities_for_type, get_capabilities_for_type,
@@ -53,6 +54,7 @@ class SDRFactory:
SDRType.HACKRF: HackRFCommandBuilder, SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder, SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder, SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
} }
@classmethod @classmethod
@@ -211,6 +213,7 @@ __all__ = [
"HackRFCommandBuilder", "HackRFCommandBuilder",
"AirspyCommandBuilder", "AirspyCommandBuilder",
"SDRPlayCommandBuilder", "SDRPlayCommandBuilder",
"USRPCommandBuilder",
# Validation # Validation
"SDRValidationError", "SDRValidationError",
"validate_frequency", "validate_frequency",
+1 -1
View File
@@ -20,8 +20,8 @@ class SDRType(Enum):
HACKRF = "hackrf" HACKRF = "hackrf"
AIRSPY = "airspy" AIRSPY = "airspy"
SDRPLAY = "sdrplay" SDRPLAY = "sdrplay"
USRP = "usrp"
# Future support # Future support
# USRP = "usrp"
# BLADE_RF = "bladerf" # BLADE_RF = "bladerf"
+3 -1
View File
@@ -53,6 +53,7 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder from .sdrplay import SDRPlayCommandBuilder
from .usrp import USRPCommandBuilder
builders = { builders = {
SDRType.RTL_SDR: RTLSDRCommandBuilder, SDRType.RTL_SDR: RTLSDRCommandBuilder,
@@ -60,6 +61,7 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
SDRType.HACKRF: HackRFCommandBuilder, SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder, SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder, SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
} }
builder_class = builders.get(sdr_type) builder_class = builders.get(sdr_type)
@@ -90,8 +92,8 @@ def _driver_to_sdr_type(driver: str) -> SDRType | None:
"airspy": SDRType.AIRSPY, "airspy": SDRType.AIRSPY,
"airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder "airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder
"sdrplay": SDRType.SDRPLAY, "sdrplay": SDRType.SDRPLAY,
"uhd": SDRType.USRP,
# Future support # Future support
# 'uhd': SDRType.USRP,
# 'bladerf': SDRType.BLADE_RF, # 'bladerf': SDRType.BLADE_RF,
} }
return mapping.get(driver.lower()) return mapping.get(driver.lower())
+153
View File
@@ -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