From 58222b34745e9838371e186fcb5fa07567196c68 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 09:24:22 +0100
Subject: [PATCH 01/14] fix(hackrf): resolve 'Tools Missing' on RPi when
hackrf_info is present
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two root causes behind HackRF showing as unavailable when tools are installed:
1. get_tool_path() didn't search /usr/local/bin on Linux. HackRF tools built
from source (as in the Dockerfile) land there, but the path wasn't checked
when sudo/service environments have a restricted PATH.
2. check_hackrf() only tested hackrf_transfer, but the health check tests
hackrf_info — both come from the same apt package but a user could have one
visible and not the other. Now either binary confirms the tools are present.
hackrf_transfer is still required for actual RX/TX operations.
Fixes #212
Co-Authored-By: Claude Sonnet 4.6
---
utils/dependencies.py | 819 ++++++++++---------
utils/subghz.py | 1748 ++++++++++++++++++++++-------------------
2 files changed, 1357 insertions(+), 1210 deletions(-)
diff --git a/utils/dependencies.py b/utils/dependencies.py
index e482bb2..01597d3 100644
--- a/utils/dependencies.py
+++ b/utils/dependencies.py
@@ -7,16 +7,16 @@ import shutil
import subprocess
from typing import Any
-logger = logging.getLogger('intercept.dependencies')
+logger = logging.getLogger("intercept.dependencies")
-# Additional paths to search for tools (e.g., /usr/sbin on Debian)
-EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
+# Additional paths to search for tools (e.g., /usr/sbin on Debian, /usr/local/bin for source builds)
+EXTRA_TOOL_PATHS = ["/usr/local/bin", "/usr/sbin", "/sbin"]
# Tools installed to non-standard locations (not on PATH)
KNOWN_TOOL_PATHS: dict[str, list[str]] = {
- 'auto_rx.py': [
- '/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
- '/opt/auto_rx/auto_rx.py',
+ "auto_rx.py": [
+ "/opt/radiosonde_auto_rx/auto_rx/auto_rx.py",
+ "/opt/auto_rx/auto_rx.py",
],
}
@@ -36,12 +36,12 @@ def get_tool_path(name: str) -> str | None:
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
# /usr/local tools with arm64 Python/runtime.
- if platform.system() == 'Darwin':
+ if platform.system() == "Darwin":
machine = platform.machine().lower()
preferred_paths: list[str] = []
- if machine in {'arm64', 'aarch64'}:
- preferred_paths.append('/opt/homebrew/bin')
- preferred_paths.append('/usr/local/bin')
+ if machine in {"arm64", "aarch64"}:
+ preferred_paths.append("/opt/homebrew/bin")
+ preferred_paths.append("/usr/local/bin")
for base in preferred_paths:
full_path = os.path.join(base, name)
@@ -78,31 +78,32 @@ def _get_soapy_env() -> dict[str, str]:
See: https://github.com/smittix/intercept/issues/77
"""
import platform
+
env = os.environ.copy()
- if platform.system() == 'Darwin':
+ if platform.system() == "Darwin":
# Homebrew paths for Apple Silicon and Intel Macs
- homebrew_paths = ['/opt/homebrew', '/usr/local']
+ homebrew_paths = ["/opt/homebrew", "/usr/local"]
lib_paths = []
module_paths = []
for base in homebrew_paths:
- lib_path = f'{base}/lib'
+ lib_path = f"{base}/lib"
if os.path.isdir(lib_path):
lib_paths.append(lib_path)
# SoapySDR modules are in lib/SoapySDR/modules
- soapy_mod_base = f'{base}/lib/SoapySDR'
+ soapy_mod_base = f"{base}/lib/SoapySDR"
if os.path.isdir(soapy_mod_base):
module_paths.append(soapy_mod_base)
if lib_paths:
- current_dyld = env.get('DYLD_LIBRARY_PATH', '')
- env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + ([current_dyld] if current_dyld else []))
+ current_dyld = env.get("DYLD_LIBRARY_PATH", "")
+ env["DYLD_LIBRARY_PATH"] = ":".join(lib_paths + ([current_dyld] if current_dyld else []))
# Set SOAPY_SDR_ROOT if we found Homebrew installation
for base in homebrew_paths:
- if os.path.isdir(f'{base}/lib/SoapySDR'):
- env['SOAPY_SDR_ROOT'] = base
+ if os.path.isdir(f"{base}/lib/SoapySDR"):
+ env["SOAPY_SDR_ROOT"] = base
break
return env
@@ -114,7 +115,7 @@ def check_soapy_factory(factory_name: str) -> bool:
# Run SoapySDRUtil --info and look for the factory in 'Available factories'
# Use macOS-aware environment to find Homebrew-installed modules
env = _get_soapy_env()
- result = subprocess.run(['SoapySDRUtil', '--info'], capture_output=True, text=True, env=env)
+ result = subprocess.run(["SoapySDRUtil", "--info"], capture_output=True, text=True, env=env)
if result.returncode != 0:
return False
@@ -134,395 +135,390 @@ def check_soapy_factory(factory_name: str) -> bool:
# Comprehensive tool dependency definitions
TOOL_DEPENDENCIES = {
- 'pager': {
- 'name': 'Pager Decoding',
- 'tools': {
- 'rtl_fm': {
- 'required': True,
- 'description': 'RTL-SDR FM demodulator',
- 'install': {
- 'apt': 'sudo apt install rtl-sdr',
- 'brew': 'brew install librtlsdr',
- 'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
- }
- },
- 'multimon-ng': {
- 'required': True,
- 'description': 'Digital transmission decoder',
- 'install': {
- 'apt': 'sudo apt install multimon-ng',
- 'brew': 'brew install multimon-ng',
- 'manual': 'https://github.com/EliasOenal/multimon-ng'
- }
- },
- 'rtl_test': {
- 'required': False,
- 'description': 'RTL-SDR device detection',
- 'install': {
- 'apt': 'sudo apt install rtl-sdr',
- 'brew': 'brew install librtlsdr',
- 'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
- }
- }
- }
- },
- 'sensor': {
- 'name': '433MHz Sensors',
- 'tools': {
- 'rtl_433': {
- 'required': True,
- 'description': 'ISM band decoder for sensors, weather stations, TPMS',
- 'install': {
- 'apt': 'sudo apt install rtl-433',
- 'brew': 'brew install rtl_433',
- 'manual': 'https://github.com/merbanan/rtl_433'
- }
- }
- }
- },
- 'wifi': {
- 'name': 'WiFi Reconnaissance',
- 'tools': {
- 'airmon-ng': {
- 'required': True,
- 'description': 'Monitor mode controller',
- 'install': {
- 'apt': 'sudo apt install aircrack-ng',
- 'brew': 'Not available on macOS',
- 'manual': 'https://aircrack-ng.org'
- }
- },
- 'airodump-ng': {
- 'required': True,
- 'description': 'WiFi network scanner',
- 'install': {
- 'apt': 'sudo apt install aircrack-ng',
- 'brew': 'Not available on macOS',
- 'manual': 'https://aircrack-ng.org'
- }
- },
- 'aireplay-ng': {
- 'required': False,
- 'description': 'Deauthentication / packet injection',
- 'install': {
- 'apt': 'sudo apt install aircrack-ng',
- 'brew': 'Not available on macOS',
- 'manual': 'https://aircrack-ng.org'
- }
- },
- 'aircrack-ng': {
- 'required': False,
- 'description': 'Handshake verification',
- 'install': {
- 'apt': 'sudo apt install aircrack-ng',
- 'brew': 'brew install aircrack-ng',
- 'manual': 'https://aircrack-ng.org'
- }
- },
- 'hcxdumptool': {
- 'required': False,
- 'description': 'PMKID capture tool',
- 'install': {
- 'apt': 'sudo apt install hcxdumptool',
- 'brew': 'brew install hcxtools',
- 'manual': 'https://github.com/ZerBea/hcxdumptool'
- }
- },
- 'hcxpcapngtool': {
- 'required': False,
- 'description': 'PMKID hash extractor',
- 'install': {
- 'apt': 'sudo apt install hcxtools',
- 'brew': 'brew install hcxtools',
- 'manual': 'https://github.com/ZerBea/hcxtools'
- }
- }
- }
- },
- 'bluetooth': {
- 'name': 'Bluetooth Scanning',
- 'tools': {
- 'hcitool': {
- 'required': False,
- 'description': 'Bluetooth HCI tool (legacy)',
- 'install': {
- 'apt': 'sudo apt install bluez',
- 'brew': 'Not available on macOS (use native)',
- 'manual': 'http://www.bluez.org'
- }
- },
- 'bluetoothctl': {
- 'required': True,
- 'description': 'Modern Bluetooth controller',
- 'install': {
- 'apt': 'sudo apt install bluez',
- 'brew': 'Not available on macOS (use native)',
- 'manual': 'http://www.bluez.org'
- }
- },
- 'hciconfig': {
- 'required': False,
- 'description': 'Bluetooth adapter configuration',
- 'install': {
- 'apt': 'sudo apt install bluez',
- 'brew': 'Not available on macOS',
- 'manual': 'http://www.bluez.org'
- }
- }
- }
- },
- 'aircraft': {
- 'name': 'Aircraft Tracking (ADS-B)',
- 'tools': {
- 'dump1090': {
- 'required': False,
- 'description': 'Mode S / ADS-B decoder (preferred)',
- 'install': {
- 'apt': 'sudo apt install dump1090-mutability (or build dump1090-fa from source)',
- 'brew': 'brew install dump1090-mutability',
- 'manual': 'https://github.com/flightaware/dump1090'
+ "pager": {
+ "name": "Pager Decoding",
+ "tools": {
+ "rtl_fm": {
+ "required": True,
+ "description": "RTL-SDR FM demodulator",
+ "install": {
+ "apt": "sudo apt install rtl-sdr",
+ "brew": "brew install librtlsdr",
+ "manual": "https://osmocom.org/projects/rtl-sdr/wiki",
},
- 'alternatives': ['dump1090-mutability', 'dump1090-fa']
},
- 'rtl_adsb': {
- 'required': False,
- 'description': 'Simple ADS-B decoder',
- 'install': {
- 'apt': 'sudo apt install rtl-sdr',
- 'brew': 'brew install librtlsdr',
- 'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
- }
- }
- }
- },
- 'acars': {
- 'name': 'Aircraft Messaging (ACARS)',
- 'tools': {
- 'acarsdec': {
- 'required': True,
- 'description': 'ACARS VHF decoder',
- 'install': {
- 'apt': 'Run ./setup.sh (builds from source)',
- 'brew': 'Run ./setup.sh (builds from source)',
- 'manual': 'https://github.com/TLeconte/acarsdec'
- }
- }
- }
- },
- 'ais': {
- 'name': 'Vessel Tracking (AIS)',
- 'tools': {
- 'AIS-catcher': {
- 'required': True,
- 'description': 'AIS receiver and decoder',
- 'install': {
- 'apt': 'Download .deb from https://github.com/jvde-github/AIS-catcher/releases',
- 'brew': 'brew install aiscatcher',
- 'manual': 'https://github.com/jvde-github/AIS-catcher/releases'
- }
- }
- }
- },
- 'aprs': {
- 'name': 'APRS Tracking',
- 'tools': {
- 'direwolf': {
- 'required': False,
- 'description': 'APRS/packet radio decoder (preferred)',
- 'install': {
- 'apt': 'sudo apt install direwolf',
- 'brew': 'brew install direwolf',
- 'manual': 'https://github.com/wb2osz/direwolf'
- }
- },
- 'multimon-ng': {
- 'required': False,
- 'description': 'Alternative AFSK1200 decoder',
- 'install': {
- 'apt': 'sudo apt install multimon-ng',
- 'brew': 'brew install multimon-ng',
- 'manual': 'https://github.com/EliasOenal/multimon-ng'
- }
- }
- }
- },
- 'satellite': {
- 'name': 'Satellite Tracking',
- 'tools': {
- 'skyfield': {
- 'required': True,
- 'description': 'Python orbital mechanics library',
- 'install': {
- 'pip': 'pip install skyfield',
- 'manual': 'https://rhodesmill.org/skyfield/'
+ "multimon-ng": {
+ "required": True,
+ "description": "Digital transmission decoder",
+ "install": {
+ "apt": "sudo apt install multimon-ng",
+ "brew": "brew install multimon-ng",
+ "manual": "https://github.com/EliasOenal/multimon-ng",
},
- 'python_module': True
- }
- }
+ },
+ "rtl_test": {
+ "required": False,
+ "description": "RTL-SDR device detection",
+ "install": {
+ "apt": "sudo apt install rtl-sdr",
+ "brew": "brew install librtlsdr",
+ "manual": "https://osmocom.org/projects/rtl-sdr/wiki",
+ },
+ },
+ },
},
- 'sdr_hardware': {
- 'name': 'SDR Hardware Support',
- 'tools': {
- 'SoapySDRUtil': {
- 'required': False,
- 'description': 'Universal SDR abstraction (required for LimeSDR, HackRF)',
- 'install': {
- 'apt': 'sudo apt install soapysdr-tools',
- 'brew': 'brew install soapysdr',
- 'manual': 'https://github.com/pothosware/SoapySDR'
- }
- },
- 'rx_fm': {
- 'required': False,
- 'description': 'SoapySDR FM receiver (for non-RTL hardware)',
- 'install': {
- 'manual': 'Part of SoapySDR utilities or build from source'
- }
- },
- 'LimeUtil': {
- 'required': False,
- 'description': 'LimeSDR native utilities',
- 'install': {
- 'apt': 'sudo apt install limesuite',
- 'brew': 'brew install limesuite',
- 'manual': 'https://github.com/myriadrf/LimeSuite'
- }
- },
- 'SoapyLMS7': {
- 'required': False,
- 'description': 'SoapySDR plugin for LimeSDR',
- 'soapy_factory': 'lime',
- 'install': {
- 'apt': 'sudo apt install soapysdr-module-lms7',
- 'brew': 'brew install soapylms7',
- 'manual': 'https://github.com/myriadrf/LimeSuite'
- }
- },
- 'hackrf_info': {
- 'required': False,
- 'description': 'HackRF native utilities',
- 'install': {
- 'apt': 'sudo apt install hackrf',
- 'brew': 'brew install hackrf',
- 'manual': 'https://github.com/greatscottgadgets/hackrf'
- }
- },
- 'SoapyHackRF': {
- 'required': False,
- 'description': 'SoapySDR plugin for HackRF',
- 'soapy_factory': 'hackrf',
- 'install': {
- 'apt': 'sudo apt install soapysdr-module-hackrf',
- 'brew': 'brew install soapyhackrf',
- 'manual': 'https://github.com/pothosware/SoapyHackRF'
- }
- },
- 'readsb': {
- 'required': False,
- 'description': 'ADS-B decoder with SoapySDR support',
- 'install': {
- 'apt': 'Build from source with SoapySDR support',
- 'brew': 'Build from source with SoapySDR support',
- 'manual': 'https://github.com/wiedehopf/readsb'
- }
+ "sensor": {
+ "name": "433MHz Sensors",
+ "tools": {
+ "rtl_433": {
+ "required": True,
+ "description": "ISM band decoder for sensors, weather stations, TPMS",
+ "install": {
+ "apt": "sudo apt install rtl-433",
+ "brew": "brew install rtl_433",
+ "manual": "https://github.com/merbanan/rtl_433",
+ },
}
- }
+ },
},
- 'subghz': {
- 'name': 'SubGHz Transceiver',
- 'tools': {
- 'hackrf_transfer': {
- 'required': True,
- 'description': 'HackRF IQ capture and replay',
- 'install': {
- 'apt': 'sudo apt install hackrf',
- 'brew': 'brew install hackrf',
- 'manual': 'https://github.com/greatscottgadgets/hackrf'
- }
+ "wifi": {
+ "name": "WiFi Reconnaissance",
+ "tools": {
+ "airmon-ng": {
+ "required": True,
+ "description": "Monitor mode controller",
+ "install": {
+ "apt": "sudo apt install aircrack-ng",
+ "brew": "Not available on macOS",
+ "manual": "https://aircrack-ng.org",
+ },
},
- 'hackrf_sweep': {
- 'required': False,
- 'description': 'HackRF wideband spectrum sweep',
- 'install': {
- 'apt': 'sudo apt install hackrf',
- 'brew': 'brew install hackrf',
- 'manual': 'https://github.com/greatscottgadgets/hackrf'
- }
+ "airodump-ng": {
+ "required": True,
+ "description": "WiFi network scanner",
+ "install": {
+ "apt": "sudo apt install aircrack-ng",
+ "brew": "Not available on macOS",
+ "manual": "https://aircrack-ng.org",
+ },
},
- 'rtl_433': {
- 'required': False,
- 'description': 'Protocol decoder for SubGHz signals',
- 'install': {
- 'apt': 'sudo apt install rtl-433',
- 'brew': 'brew install rtl_433',
- 'manual': 'https://github.com/merbanan/rtl_433'
- }
- }
- }
+ "aireplay-ng": {
+ "required": False,
+ "description": "Deauthentication / packet injection",
+ "install": {
+ "apt": "sudo apt install aircrack-ng",
+ "brew": "Not available on macOS",
+ "manual": "https://aircrack-ng.org",
+ },
+ },
+ "aircrack-ng": {
+ "required": False,
+ "description": "Handshake verification",
+ "install": {
+ "apt": "sudo apt install aircrack-ng",
+ "brew": "brew install aircrack-ng",
+ "manual": "https://aircrack-ng.org",
+ },
+ },
+ "hcxdumptool": {
+ "required": False,
+ "description": "PMKID capture tool",
+ "install": {
+ "apt": "sudo apt install hcxdumptool",
+ "brew": "brew install hcxtools",
+ "manual": "https://github.com/ZerBea/hcxdumptool",
+ },
+ },
+ "hcxpcapngtool": {
+ "required": False,
+ "description": "PMKID hash extractor",
+ "install": {
+ "apt": "sudo apt install hcxtools",
+ "brew": "brew install hcxtools",
+ "manual": "https://github.com/ZerBea/hcxtools",
+ },
+ },
+ },
},
- 'radiosonde': {
- 'name': 'Radiosonde Tracking',
- 'tools': {
- 'auto_rx.py': {
- 'required': True,
- 'description': 'Radiosonde weather balloon decoder',
- 'install': {
- 'apt': 'Run ./setup.sh (clones from GitHub)',
- 'brew': 'Run ./setup.sh (clones from GitHub)',
- 'manual': 'https://github.com/projecthorus/radiosonde_auto_rx'
- }
- }
- }
+ "bluetooth": {
+ "name": "Bluetooth Scanning",
+ "tools": {
+ "hcitool": {
+ "required": False,
+ "description": "Bluetooth HCI tool (legacy)",
+ "install": {
+ "apt": "sudo apt install bluez",
+ "brew": "Not available on macOS (use native)",
+ "manual": "http://www.bluez.org",
+ },
+ },
+ "bluetoothctl": {
+ "required": True,
+ "description": "Modern Bluetooth controller",
+ "install": {
+ "apt": "sudo apt install bluez",
+ "brew": "Not available on macOS (use native)",
+ "manual": "http://www.bluez.org",
+ },
+ },
+ "hciconfig": {
+ "required": False,
+ "description": "Bluetooth adapter configuration",
+ "install": {
+ "apt": "sudo apt install bluez",
+ "brew": "Not available on macOS",
+ "manual": "http://www.bluez.org",
+ },
+ },
+ },
},
- 'tscm': {
- 'name': 'TSCM Counter-Surveillance',
- 'tools': {
- 'rtl_power': {
- 'required': False,
- 'description': 'Wideband spectrum sweep for RF analysis',
- 'install': {
- 'apt': 'sudo apt install rtl-sdr',
- 'brew': 'brew install librtlsdr',
- 'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
- }
+ "aircraft": {
+ "name": "Aircraft Tracking (ADS-B)",
+ "tools": {
+ "dump1090": {
+ "required": False,
+ "description": "Mode S / ADS-B decoder (preferred)",
+ "install": {
+ "apt": "sudo apt install dump1090-mutability (or build dump1090-fa from source)",
+ "brew": "brew install dump1090-mutability",
+ "manual": "https://github.com/flightaware/dump1090",
+ },
+ "alternatives": ["dump1090-mutability", "dump1090-fa"],
},
- 'rtl_fm': {
- 'required': True,
- 'description': 'RF signal demodulation',
- 'install': {
- 'apt': 'sudo apt install rtl-sdr',
- 'brew': 'brew install librtlsdr',
- 'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
- }
+ "rtl_adsb": {
+ "required": False,
+ "description": "Simple ADS-B decoder",
+ "install": {
+ "apt": "sudo apt install rtl-sdr",
+ "brew": "brew install librtlsdr",
+ "manual": "https://osmocom.org/projects/rtl-sdr/wiki",
+ },
},
- 'rtl_433': {
- 'required': False,
- 'description': 'ISM band device decoding',
- 'install': {
- 'apt': 'sudo apt install rtl-433',
- 'brew': 'brew install rtl_433',
- 'manual': 'https://github.com/merbanan/rtl_433'
- }
- },
- 'airmon-ng': {
- 'required': False,
- 'description': 'WiFi monitor mode for network scanning',
- 'install': {
- 'apt': 'sudo apt install aircrack-ng',
- 'brew': 'Not available on macOS',
- 'manual': 'https://aircrack-ng.org'
- }
- },
- 'bluetoothctl': {
- 'required': False,
- 'description': 'Bluetooth device scanning',
- 'install': {
- 'apt': 'sudo apt install bluez',
- 'brew': 'Not available on macOS (use native)',
- 'manual': 'http://www.bluez.org'
- }
+ },
+ },
+ "acars": {
+ "name": "Aircraft Messaging (ACARS)",
+ "tools": {
+ "acarsdec": {
+ "required": True,
+ "description": "ACARS VHF decoder",
+ "install": {
+ "apt": "Run ./setup.sh (builds from source)",
+ "brew": "Run ./setup.sh (builds from source)",
+ "manual": "https://github.com/TLeconte/acarsdec",
+ },
}
- }
+ },
+ },
+ "ais": {
+ "name": "Vessel Tracking (AIS)",
+ "tools": {
+ "AIS-catcher": {
+ "required": True,
+ "description": "AIS receiver and decoder",
+ "install": {
+ "apt": "Download .deb from https://github.com/jvde-github/AIS-catcher/releases",
+ "brew": "brew install aiscatcher",
+ "manual": "https://github.com/jvde-github/AIS-catcher/releases",
+ },
+ }
+ },
+ },
+ "aprs": {
+ "name": "APRS Tracking",
+ "tools": {
+ "direwolf": {
+ "required": False,
+ "description": "APRS/packet radio decoder (preferred)",
+ "install": {
+ "apt": "sudo apt install direwolf",
+ "brew": "brew install direwolf",
+ "manual": "https://github.com/wb2osz/direwolf",
+ },
+ },
+ "multimon-ng": {
+ "required": False,
+ "description": "Alternative AFSK1200 decoder",
+ "install": {
+ "apt": "sudo apt install multimon-ng",
+ "brew": "brew install multimon-ng",
+ "manual": "https://github.com/EliasOenal/multimon-ng",
+ },
+ },
+ },
+ },
+ "satellite": {
+ "name": "Satellite Tracking",
+ "tools": {
+ "skyfield": {
+ "required": True,
+ "description": "Python orbital mechanics library",
+ "install": {"pip": "pip install skyfield", "manual": "https://rhodesmill.org/skyfield/"},
+ "python_module": True,
+ }
+ },
+ },
+ "sdr_hardware": {
+ "name": "SDR Hardware Support",
+ "tools": {
+ "SoapySDRUtil": {
+ "required": False,
+ "description": "Universal SDR abstraction (required for LimeSDR, HackRF)",
+ "install": {
+ "apt": "sudo apt install soapysdr-tools",
+ "brew": "brew install soapysdr",
+ "manual": "https://github.com/pothosware/SoapySDR",
+ },
+ },
+ "rx_fm": {
+ "required": False,
+ "description": "SoapySDR FM receiver (for non-RTL hardware)",
+ "install": {"manual": "Part of SoapySDR utilities or build from source"},
+ },
+ "LimeUtil": {
+ "required": False,
+ "description": "LimeSDR native utilities",
+ "install": {
+ "apt": "sudo apt install limesuite",
+ "brew": "brew install limesuite",
+ "manual": "https://github.com/myriadrf/LimeSuite",
+ },
+ },
+ "SoapyLMS7": {
+ "required": False,
+ "description": "SoapySDR plugin for LimeSDR",
+ "soapy_factory": "lime",
+ "install": {
+ "apt": "sudo apt install soapysdr-module-lms7",
+ "brew": "brew install soapylms7",
+ "manual": "https://github.com/myriadrf/LimeSuite",
+ },
+ },
+ "hackrf_info": {
+ "required": False,
+ "description": "HackRF native utilities",
+ "install": {
+ "apt": "sudo apt install hackrf",
+ "brew": "brew install hackrf",
+ "manual": "https://github.com/greatscottgadgets/hackrf",
+ },
+ },
+ "SoapyHackRF": {
+ "required": False,
+ "description": "SoapySDR plugin for HackRF",
+ "soapy_factory": "hackrf",
+ "install": {
+ "apt": "sudo apt install soapysdr-module-hackrf",
+ "brew": "brew install soapyhackrf",
+ "manual": "https://github.com/pothosware/SoapyHackRF",
+ },
+ },
+ "readsb": {
+ "required": False,
+ "description": "ADS-B decoder with SoapySDR support",
+ "install": {
+ "apt": "Build from source with SoapySDR support",
+ "brew": "Build from source with SoapySDR support",
+ "manual": "https://github.com/wiedehopf/readsb",
+ },
+ },
+ },
+ },
+ "subghz": {
+ "name": "SubGHz Transceiver",
+ "tools": {
+ "hackrf_transfer": {
+ "required": True,
+ "description": "HackRF IQ capture and replay",
+ "install": {
+ "apt": "sudo apt install hackrf",
+ "brew": "brew install hackrf",
+ "manual": "https://github.com/greatscottgadgets/hackrf",
+ },
+ },
+ "hackrf_sweep": {
+ "required": False,
+ "description": "HackRF wideband spectrum sweep",
+ "install": {
+ "apt": "sudo apt install hackrf",
+ "brew": "brew install hackrf",
+ "manual": "https://github.com/greatscottgadgets/hackrf",
+ },
+ },
+ "rtl_433": {
+ "required": False,
+ "description": "Protocol decoder for SubGHz signals",
+ "install": {
+ "apt": "sudo apt install rtl-433",
+ "brew": "brew install rtl_433",
+ "manual": "https://github.com/merbanan/rtl_433",
+ },
+ },
+ },
+ },
+ "radiosonde": {
+ "name": "Radiosonde Tracking",
+ "tools": {
+ "auto_rx.py": {
+ "required": True,
+ "description": "Radiosonde weather balloon decoder",
+ "install": {
+ "apt": "Run ./setup.sh (clones from GitHub)",
+ "brew": "Run ./setup.sh (clones from GitHub)",
+ "manual": "https://github.com/projecthorus/radiosonde_auto_rx",
+ },
+ }
+ },
+ },
+ "tscm": {
+ "name": "TSCM Counter-Surveillance",
+ "tools": {
+ "rtl_power": {
+ "required": False,
+ "description": "Wideband spectrum sweep for RF analysis",
+ "install": {
+ "apt": "sudo apt install rtl-sdr",
+ "brew": "brew install librtlsdr",
+ "manual": "https://osmocom.org/projects/rtl-sdr/wiki",
+ },
+ },
+ "rtl_fm": {
+ "required": True,
+ "description": "RF signal demodulation",
+ "install": {
+ "apt": "sudo apt install rtl-sdr",
+ "brew": "brew install librtlsdr",
+ "manual": "https://osmocom.org/projects/rtl-sdr/wiki",
+ },
+ },
+ "rtl_433": {
+ "required": False,
+ "description": "ISM band device decoding",
+ "install": {
+ "apt": "sudo apt install rtl-433",
+ "brew": "brew install rtl_433",
+ "manual": "https://github.com/merbanan/rtl_433",
+ },
+ },
+ "airmon-ng": {
+ "required": False,
+ "description": "WiFi monitor mode for network scanning",
+ "install": {
+ "apt": "sudo apt install aircrack-ng",
+ "brew": "Not available on macOS",
+ "manual": "https://aircrack-ng.org",
+ },
+ },
+ "bluetoothctl": {
+ "required": False,
+ "description": "Bluetooth device scanning",
+ "install": {
+ "apt": "sudo apt install bluez",
+ "brew": "Not available on macOS (use native)",
+ "manual": "http://www.bluez.org",
+ },
+ },
+ },
},
}
@@ -532,16 +528,11 @@ def check_all_dependencies() -> dict[str, dict[str, Any]]:
results: dict[str, dict[str, Any]] = {}
for mode, config in TOOL_DEPENDENCIES.items():
- mode_result = {
- 'name': config['name'],
- 'tools': {},
- 'ready': True,
- 'missing_required': []
- }
+ mode_result = {"name": config["name"], "tools": {}, "ready": True, "missing_required": []}
- for tool, tool_config in config['tools'].items():
+ for tool, tool_config in config["tools"].items():
# Check if it's a Python module
- if tool_config.get('python_module'):
+ if tool_config.get("python_module"):
try:
__import__(tool)
installed = True
@@ -549,23 +540,23 @@ def check_all_dependencies() -> dict[str, dict[str, Any]]:
logger.debug(f"Failed to import {tool}: {type(e).__name__}: {e}")
installed = False
# Check using SoapySDRUtil if specified
- elif tool_config.get('soapy_factory'):
- installed = check_soapy_factory(tool_config['soapy_factory'])
+ elif tool_config.get("soapy_factory"):
+ installed = check_soapy_factory(tool_config["soapy_factory"])
else:
# Check for alternatives
- alternatives = tool_config.get('alternatives', [])
+ alternatives = tool_config.get("alternatives", [])
installed = check_tool(tool) or any(check_tool(alt) for alt in alternatives)
- mode_result['tools'][tool] = {
- 'installed': installed,
- 'required': tool_config['required'],
- 'description': tool_config['description'],
- 'install': tool_config['install']
+ mode_result["tools"][tool] = {
+ "installed": installed,
+ "required": tool_config["required"],
+ "description": tool_config["description"],
+ "install": tool_config["install"],
}
- if tool_config['required'] and not installed:
- mode_result['ready'] = False
- mode_result['missing_required'].append(tool)
+ if tool_config["required"] and not installed:
+ mode_result["ready"] = False
+ mode_result["missing_required"].append(tool)
results[mode] = mode_result
diff --git a/utils/subghz.py b/utils/subghz.py
index 5977946..54757d5 100644
--- a/utils/subghz.py
+++ b/utils/subghz.py
@@ -38,12 +38,13 @@ from utils.dependencies import get_tool_path
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
-logger = get_logger('intercept.subghz')
+logger = get_logger("intercept.subghz")
@dataclass
class SubGhzCapture:
"""Metadata for a saved IQ capture."""
+
capture_id: str
filename: str
frequency_hz: int
@@ -53,15 +54,15 @@ class SubGhzCapture:
timestamp: str
duration_seconds: float = 0.0
size_bytes: int = 0
- label: str = ''
- label_source: str = ''
+ label: str = ""
+ label_source: str = ""
decoded_protocols: list[str] = field(default_factory=list)
bursts: list[dict] = field(default_factory=list)
- modulation_hint: str = ''
+ modulation_hint: str = ""
modulation_confidence: float = 0.0
- protocol_hint: str = ''
- dominant_fingerprint: str = ''
- fingerprint_group: str = ''
+ protocol_hint: str = ""
+ dominant_fingerprint: str = ""
+ fingerprint_group: str = ""
fingerprint_group_size: int = 0
trigger_enabled: bool = False
trigger_pre_seconds: float = 0.0
@@ -69,39 +70,40 @@ class SubGhzCapture:
def to_dict(self) -> dict:
return {
- 'id': self.capture_id,
- 'filename': self.filename,
- 'frequency_hz': self.frequency_hz,
- 'sample_rate': self.sample_rate,
- 'lna_gain': self.lna_gain,
- 'vga_gain': self.vga_gain,
- 'timestamp': self.timestamp,
- 'duration_seconds': self.duration_seconds,
- 'size_bytes': self.size_bytes,
- 'label': self.label,
- 'label_source': self.label_source,
- 'decoded_protocols': self.decoded_protocols,
- 'bursts': self.bursts,
- 'modulation_hint': self.modulation_hint,
- 'modulation_confidence': self.modulation_confidence,
- 'protocol_hint': self.protocol_hint,
- 'dominant_fingerprint': self.dominant_fingerprint,
- 'fingerprint_group': self.fingerprint_group,
- 'fingerprint_group_size': self.fingerprint_group_size,
- 'trigger_enabled': self.trigger_enabled,
- 'trigger_pre_seconds': self.trigger_pre_seconds,
- 'trigger_post_seconds': self.trigger_post_seconds,
+ "id": self.capture_id,
+ "filename": self.filename,
+ "frequency_hz": self.frequency_hz,
+ "sample_rate": self.sample_rate,
+ "lna_gain": self.lna_gain,
+ "vga_gain": self.vga_gain,
+ "timestamp": self.timestamp,
+ "duration_seconds": self.duration_seconds,
+ "size_bytes": self.size_bytes,
+ "label": self.label,
+ "label_source": self.label_source,
+ "decoded_protocols": self.decoded_protocols,
+ "bursts": self.bursts,
+ "modulation_hint": self.modulation_hint,
+ "modulation_confidence": self.modulation_confidence,
+ "protocol_hint": self.protocol_hint,
+ "dominant_fingerprint": self.dominant_fingerprint,
+ "fingerprint_group": self.fingerprint_group,
+ "fingerprint_group_size": self.fingerprint_group_size,
+ "trigger_enabled": self.trigger_enabled,
+ "trigger_pre_seconds": self.trigger_pre_seconds,
+ "trigger_post_seconds": self.trigger_post_seconds,
}
@dataclass
class SweepPoint:
"""A single frequency/power data point from hackrf_sweep."""
+
freq_mhz: float
power_dbm: float
def to_dict(self) -> dict:
- return {'freq': self.freq_mhz, 'power': self.power_dbm}
+ return {"freq": self.freq_mhz, "power": self.power_dbm}
class SubGhzManager:
@@ -112,8 +114,8 @@ class SubGhzManager:
"""
def __init__(self, data_dir: str | Path | None = None):
- self._data_dir = Path(data_dir) if data_dir else Path('data/subghz')
- self._captures_dir = self._data_dir / 'captures'
+ self._data_dir = Path(data_dir) if data_dir else Path("data/subghz")
+ self._captures_dir = self._data_dir / "captures"
self._captures_dir.mkdir(parents=True, exist_ok=True)
# Process state
@@ -144,9 +146,9 @@ class SubGhzManager:
self._rx_trigger_first_burst_start: float | None = None
self._rx_trigger_last_burst_end: float | None = None
self._rx_autostop_pending = False
- self._rx_modulation_hint = ''
+ self._rx_modulation_hint = ""
self._rx_modulation_confidence = 0.0
- self._rx_protocol_hint = ''
+ self._rx_protocol_hint = ""
self._rx_fingerprint_counts: dict[str, int] = {}
# Decode state
@@ -158,7 +160,7 @@ class SubGhzManager:
# TX state
self._tx_start_time: float = 0
self._tx_watchdog: threading.Timer | None = None
- self._tx_capture_id: str = ''
+ self._tx_capture_id: str = ""
self._tx_temp_file: Path | None = None
# Sweep state
@@ -187,23 +189,28 @@ class SubGhzManager:
except Exception as e:
logger.error(f"Error in SubGHz callback: {e}")
- # ------------------------------------------------------------------
- # Tool detection
- # ------------------------------------------------------------------
-
- def _resolve_tool(self, name: str) -> str | None:
- """Resolve executable path via PATH first, then platform-aware fallbacks."""
- return shutil.which(name) or get_tool_path(name)
-
- def check_hackrf(self) -> bool:
- if self._hackrf_available is None:
- self._hackrf_available = self._resolve_tool('hackrf_transfer') is not None
- return self._hackrf_available
-
- def check_hackrf_info(self) -> bool:
- if self._hackrf_info_available is None:
- self._hackrf_info_available = self._resolve_tool('hackrf_info') is not None
- return self._hackrf_info_available
+ # ------------------------------------------------------------------
+ # Tool detection
+ # ------------------------------------------------------------------
+
+ def _resolve_tool(self, name: str) -> str | None:
+ """Resolve executable path via PATH first, then platform-aware fallbacks."""
+ return shutil.which(name) or get_tool_path(name)
+
+ def check_hackrf(self) -> bool:
+ if self._hackrf_available is None:
+ # Either binary confirms the hackrf package is installed. hackrf_transfer
+ # may be absent when only soapysdr-module-hackrf was installed, but
+ # hackrf_info alone is sufficient to detect and report devices.
+ self._hackrf_available = (
+ self._resolve_tool("hackrf_transfer") is not None or self._resolve_tool("hackrf_info") is not None
+ )
+ return self._hackrf_available
+
+ def check_hackrf_info(self) -> bool:
+ if self._hackrf_info_available is None:
+ self._hackrf_info_available = self._resolve_tool("hackrf_info") is not None
+ return self._hackrf_info_available
def check_hackrf_device(self) -> bool | None:
"""Return True if a HackRF device is detected, False if not, or None if detection unavailable."""
@@ -216,6 +223,7 @@ class SubGhzManager:
try:
from utils.sdr.detection import detect_hackrf_devices
+
connected = len(detect_hackrf_devices()) > 0
except Exception as exc:
logger.debug(f"HackRF device detection failed: {exc}")
@@ -229,18 +237,18 @@ class SubGhzManager:
"""Return an error string if HackRF is explicitly not detected."""
detected = self.check_hackrf_device()
if detected is False:
- return 'HackRF device not detected'
+ return "HackRF device not detected"
return None
- def check_rtl433(self) -> bool:
- if self._rtl433_available is None:
- self._rtl433_available = self._resolve_tool('rtl_433') is not None
- return self._rtl433_available
-
- def check_sweep(self) -> bool:
- if self._sweep_available is None:
- self._sweep_available = self._resolve_tool('hackrf_sweep') is not None
- return self._sweep_available
+ def check_rtl433(self) -> bool:
+ if self._rtl433_available is None:
+ self._rtl433_available = self._resolve_tool("rtl_433") is not None
+ return self._rtl433_available
+
+ def check_sweep(self) -> bool:
+ if self._sweep_available is None:
+ self._sweep_available = self._resolve_tool("hackrf_sweep") is not None
+ return self._sweep_available
# ------------------------------------------------------------------
# Status
@@ -251,19 +259,19 @@ class SubGhzManager:
"""Return current active mode or 'idle'."""
with self._lock:
if self._rx_process and self._rx_process.poll() is None:
- return 'rx'
+ return "rx"
if self._decode_process and self._decode_process.poll() is None:
- return 'decode'
+ return "decode"
if self._tx_process and self._tx_process.poll() is None:
- return 'tx'
+ return "tx"
if self._sweep_process and self._sweep_process.poll() is None:
- return 'sweep'
- return 'idle'
+ return "sweep"
+ return "idle"
def get_status(self) -> dict:
mode = self.active_mode
hackrf_info_available = self.check_hackrf_info()
- detect_paused = mode in {'rx', 'decode', 'tx', 'sweep'}
+ detect_paused = mode in {"rx", "decode", "tx", "sweep"}
if detect_paused:
# Avoid probing HackRF while a stream is active. A fresh "disconnected"
# cache result should still surface to the UI, otherwise mark unknown.
@@ -274,86 +282,97 @@ class SubGhzManager:
else:
hackrf_connected = self.check_hackrf_device()
status: dict = {
- 'mode': mode,
- 'hackrf_available': self.check_hackrf(),
- 'hackrf_info_available': hackrf_info_available,
- 'hackrf_connected': hackrf_connected,
- 'hackrf_detection_paused': detect_paused,
- 'rtl433_available': self.check_rtl433(),
- 'sweep_available': self.check_sweep(),
+ "mode": mode,
+ "hackrf_available": self.check_hackrf(),
+ "hackrf_info_available": hackrf_info_available,
+ "hackrf_connected": hackrf_connected,
+ "hackrf_detection_paused": detect_paused,
+ "rtl433_available": self.check_rtl433(),
+ "sweep_available": self.check_sweep(),
}
- if mode == 'rx':
+ if mode == "rx":
elapsed = time.time() - self._rx_start_time if self._rx_start_time else 0
- status.update({
- 'frequency_hz': self._rx_frequency_hz,
- 'sample_rate': self._rx_sample_rate,
- 'elapsed_seconds': round(elapsed, 1),
- 'trigger_enabled': self._rx_trigger_enabled,
- 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3),
- 'trigger_post_seconds': round(self._rx_trigger_post_s, 3),
- })
- elif mode == 'decode':
+ status.update(
+ {
+ "frequency_hz": self._rx_frequency_hz,
+ "sample_rate": self._rx_sample_rate,
+ "elapsed_seconds": round(elapsed, 1),
+ "trigger_enabled": self._rx_trigger_enabled,
+ "trigger_pre_seconds": round(self._rx_trigger_pre_s, 3),
+ "trigger_post_seconds": round(self._rx_trigger_post_s, 3),
+ }
+ )
+ elif mode == "decode":
elapsed = time.time() - self._decode_start_time if self._decode_start_time else 0
- status.update({
- 'frequency_hz': self._decode_frequency_hz,
- 'sample_rate': self._decode_sample_rate,
- 'elapsed_seconds': round(elapsed, 1),
- })
- elif mode == 'tx':
+ status.update(
+ {
+ "frequency_hz": self._decode_frequency_hz,
+ "sample_rate": self._decode_sample_rate,
+ "elapsed_seconds": round(elapsed, 1),
+ }
+ )
+ elif mode == "tx":
elapsed = time.time() - self._tx_start_time if self._tx_start_time else 0
- status.update({
- 'capture_id': self._tx_capture_id,
- 'elapsed_seconds': round(elapsed, 1),
- })
+ status.update(
+ {
+ "capture_id": self._tx_capture_id,
+ "elapsed_seconds": round(elapsed, 1),
+ }
+ )
return status
# ------------------------------------------------------------------
# RECEIVE (IQ capture via hackrf_transfer -r)
# ------------------------------------------------------------------
- def start_receive(
- self,
- frequency_hz: int,
- sample_rate: int = 2000000,
+ def start_receive(
+ self,
+ frequency_hz: int,
+ sample_rate: int = 2000000,
lna_gain: int = 32,
vga_gain: int = 20,
trigger_enabled: bool = False,
trigger_pre_ms: int = 350,
- trigger_post_ms: int = 700,
- device_serial: str | None = None,
- ) -> dict:
- # Pre-lock: tool availability & device detection (blocking I/O)
- hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
- if not hackrf_transfer_path:
- return {'status': 'error', 'message': 'hackrf_transfer not found'}
- device_err = self._require_hackrf_device()
- if device_err:
- return {'status': 'error', 'message': device_err}
+ trigger_post_ms: int = 700,
+ device_serial: str | None = None,
+ ) -> dict:
+ # Pre-lock: tool availability & device detection (blocking I/O)
+ hackrf_transfer_path = self._resolve_tool("hackrf_transfer")
+ if not hackrf_transfer_path:
+ return {"status": "error", "message": "hackrf_transfer not found"}
+ device_err = self._require_hackrf_device()
+ if device_err:
+ return {"status": "error", "message": device_err}
with self._lock:
- if self.active_mode != 'idle':
- return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
+ if self.active_mode != "idle":
+ return {"status": "error", "message": f"Already running: {self.active_mode}"}
# Validate gains
lna_gain = max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))
vga_gain = max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))
# Generate filename
- ts = datetime.now().strftime('%Y%m%d_%H%M%S')
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
freq_mhz = frequency_hz / 1_000_000
basename = f"{freq_mhz:.3f}MHz_{ts}"
iq_file = self._captures_dir / f"{basename}.iq"
- cmd = [
- hackrf_transfer_path,
- '-r', str(iq_file),
- '-f', str(frequency_hz),
- '-s', str(sample_rate),
- '-l', str(lna_gain),
- '-g', str(vga_gain),
+ cmd = [
+ hackrf_transfer_path,
+ "-r",
+ str(iq_file),
+ "-f",
+ str(frequency_hz),
+ "-s",
+ str(sample_rate),
+ "-l",
+ str(lna_gain),
+ "-g",
+ str(vga_gain),
]
if device_serial:
- cmd.extend(['-d', device_serial])
+ cmd.extend(["-d", device_serial])
logger.info(f"SubGHz RX: {' '.join(cmd)}")
@@ -362,7 +381,7 @@ class SubGhzManager:
iq_file.touch(exist_ok=True)
except OSError as e:
logger.error(f"Failed to create RX file: {e}")
- return {'status': 'error', 'message': 'Failed to create capture file'}
+ return {"status": "error", "message": "Failed to create capture file"}
self._rx_process = subprocess.Popen(
cmd,
@@ -372,13 +391,13 @@ class SubGhzManager:
register_process(self._rx_process)
try:
- self._rx_file_handle = open(iq_file, 'rb', buffering=0)
+ self._rx_file_handle = open(iq_file, "rb", buffering=0)
except OSError as e:
safe_terminate(self._rx_process)
unregister_process(self._rx_process)
self._rx_process = None
logger.error(f"Failed to open RX file: {e}")
- return {'status': 'error', 'message': 'Failed to open capture file'}
+ return {"status": "error", "message": "Failed to open capture file"}
self._rx_start_time = time.time()
self._rx_frequency_hz = frequency_hz
@@ -395,9 +414,9 @@ class SubGhzManager:
self._rx_trigger_first_burst_start = None
self._rx_trigger_last_burst_end = None
self._rx_autostop_pending = False
- self._rx_modulation_hint = ''
+ self._rx_modulation_hint = ""
self._rx_modulation_confidence = 0.0
- self._rx_protocol_hint = ''
+ self._rx_protocol_hint = ""
self._rx_fingerprint_counts = {}
# Start capture stream reader
@@ -413,41 +432,45 @@ class SubGhzManager:
daemon=True,
).start()
- self._emit({
- 'type': 'status',
- 'mode': 'rx',
- 'status': 'started',
- 'frequency_hz': frequency_hz,
- 'sample_rate': sample_rate,
- 'trigger_enabled': self._rx_trigger_enabled,
- 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3),
- 'trigger_post_seconds': round(self._rx_trigger_post_s, 3),
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "rx",
+ "status": "started",
+ "frequency_hz": frequency_hz,
+ "sample_rate": sample_rate,
+ "trigger_enabled": self._rx_trigger_enabled,
+ "trigger_pre_seconds": round(self._rx_trigger_pre_s, 3),
+ "trigger_post_seconds": round(self._rx_trigger_post_s, 3),
+ }
+ )
if self._rx_trigger_enabled:
- self._emit({
- 'type': 'info',
- 'text': (
- f'[rx] Smart trigger armed '
- f'(pre {self._rx_trigger_pre_s:.2f}s, post {self._rx_trigger_post_s:.2f}s)'
- ),
- })
+ self._emit(
+ {
+ "type": "info",
+ "text": (
+ f"[rx] Smart trigger armed "
+ f"(pre {self._rx_trigger_pre_s:.2f}s, post {self._rx_trigger_post_s:.2f}s)"
+ ),
+ }
+ )
return {
- 'status': 'started',
- 'frequency_hz': frequency_hz,
- 'sample_rate': sample_rate,
- 'file': iq_file.name,
- 'trigger_enabled': self._rx_trigger_enabled,
- 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3),
- 'trigger_post_seconds': round(self._rx_trigger_post_s, 3),
+ "status": "started",
+ "frequency_hz": frequency_hz,
+ "sample_rate": sample_rate,
+ "file": iq_file.name,
+ "trigger_enabled": self._rx_trigger_enabled,
+ "trigger_pre_seconds": round(self._rx_trigger_pre_s, 3),
+ "trigger_post_seconds": round(self._rx_trigger_post_s, 3),
}
except FileNotFoundError:
- return {'status': 'error', 'message': 'hackrf_transfer not found'}
+ return {"status": "error", "message": "hackrf_transfer not found"}
except Exception as e:
logger.error(f"Failed to start RX: {e}")
- return {'status': 'error', 'message': str(e)}
+ return {"status": "error", "message": str(e)}
def _estimate_modulation_hint(
self,
@@ -455,22 +478,22 @@ class SubGhzManager:
) -> tuple[str, float, str]:
"""Estimate coarse modulation family from raw IQ characteristics."""
if not data:
- return 'Unknown', 0.0, 'No samples'
+ return "Unknown", 0.0, "No samples"
try:
raw = np.frombuffer(data, dtype=np.int8).astype(np.float32)
if raw.size < 2048:
- return 'Unknown', 0.0, 'Insufficient samples'
+ return "Unknown", 0.0, "Insufficient samples"
i_vals = raw[0::2]
q_vals = raw[1::2]
if i_vals.size == 0 or q_vals.size == 0:
- return 'Unknown', 0.0, 'Invalid IQ frame'
+ return "Unknown", 0.0, "Invalid IQ frame"
# Light decimation for lower CPU while preserving burst shape.
i_vals = i_vals[::4]
q_vals = q_vals[::4]
if i_vals.size < 256 or q_vals.size < 256:
- return 'Unknown', 0.0, 'Short frame'
+ return "Unknown", 0.0, "Short frame"
iq = i_vals + 1j * q_vals
amp = np.abs(iq)
@@ -499,37 +522,34 @@ class SubGhzManager:
mean_run = float(high.size)
scores = {
- 'OOK/ASK': 0.0,
- 'FSK/GFSK': 0.0,
- 'PWM/PPM': 0.0,
+ "OOK/ASK": 0.0,
+ "FSK/GFSK": 0.0,
+ "PWM/PPM": 0.0,
}
# OOK: stronger amplitude contrast and moderate pulse occupancy.
- scores['OOK/ASK'] += max(0.0, min(1.0, (amp_cv - 0.22) / 0.35))
- scores['OOK/ASK'] += max(0.0, 1.0 - abs(pulse_density - 0.4) / 0.4) * 0.35
+ scores["OOK/ASK"] += max(0.0, min(1.0, (amp_cv - 0.22) / 0.35))
+ scores["OOK/ASK"] += max(0.0, 1.0 - abs(pulse_density - 0.4) / 0.4) * 0.35
# FSK: flatter amplitude, more phase movement.
- scores['FSK/GFSK'] += max(0.0, min(1.0, (phase_var - 0.45) / 0.9))
- scores['FSK/GFSK'] += max(0.0, min(1.0, (0.33 - amp_cv) / 0.28)) * 0.45
+ scores["FSK/GFSK"] += max(0.0, min(1.0, (phase_var - 0.45) / 0.9))
+ scores["FSK/GFSK"] += max(0.0, min(1.0, (0.33 - amp_cv) / 0.28)) * 0.45
# PWM/PPM: high edge density with short run lengths.
edge_density = 0.0 if mean_run <= 0 else min(1.0, 28.0 / max(mean_run, 1.0))
- scores['PWM/PPM'] += max(0.0, min(1.0, (amp_cv - 0.28) / 0.45))
- scores['PWM/PPM'] += edge_density * 0.6
+ scores["PWM/PPM"] += max(0.0, min(1.0, (amp_cv - 0.28) / 0.45))
+ scores["PWM/PPM"] += edge_density * 0.6
best_family = max(scores, key=scores.get)
best_score = float(scores[best_family])
confidence = max(0.0, min(0.97, best_score))
if confidence < 0.25:
- return 'Unknown', confidence, 'No clear modulation signature'
+ return "Unknown", confidence, "No clear modulation signature"
- reason = (
- f'amp_cv={amp_cv:.2f} phase_var={phase_var:.2f} '
- f'pulse_density={pulse_density:.2f}'
- )
+ reason = f"amp_cv={amp_cv:.2f} phase_var={phase_var:.2f} pulse_density={pulse_density:.2f}"
return best_family, confidence, reason
except Exception:
- return 'Unknown', 0.0, 'Modulation analysis failed'
+ return "Unknown", 0.0, "Modulation analysis failed"
def _fingerprint_burst_bytes(
self,
@@ -539,20 +559,20 @@ class SubGhzManager:
) -> str:
"""Create a stable burst fingerprint for grouping similar signals."""
if not data:
- return ''
+ return ""
try:
raw = np.frombuffer(data, dtype=np.int8).astype(np.float32)
if raw.size < 512:
- return ''
+ return ""
i_vals = raw[0::2]
q_vals = raw[1::2]
if i_vals.size == 0 or q_vals.size == 0:
- return ''
+ return ""
amp = np.sqrt(i_vals * i_vals + q_vals * q_vals)
if amp.size < 64:
- return ''
+ return ""
# Normalize and downsample envelope into a fixed-size shape vector.
amp = amp - float(np.median(amp))
@@ -571,12 +591,12 @@ class SubGhzManager:
sr_khz = int(max(1, round(sample_rate / 1000)))
payload = (
quant.tobytes()
- + burst_ms.to_bytes(2, 'little', signed=False)
- + sr_khz.to_bytes(2, 'little', signed=False)
+ + burst_ms.to_bytes(2, "little", signed=False)
+ + sr_khz.to_bytes(2, "little", signed=False)
)
return hashlib.sha1(payload).hexdigest()[:16]
except Exception:
- return ''
+ return ""
def _protocol_hint_from_capture(
self,
@@ -585,22 +605,22 @@ class SubGhzManager:
burst_count: int,
) -> str:
freq = frequency_hz / 1_000_000
- mod = (modulation_hint or '').upper()
+ mod = (modulation_hint or "").upper()
if burst_count <= 0:
- return 'No burst activity'
- if 433.70 <= freq <= 434.10 and 'OOK' in mod and burst_count >= 2:
- return 'Likely weather sensor / simple remote telemetry'
- if 868.0 <= freq <= 870.0 and 'OOK' in mod:
- return 'Likely EU ISM OOK sensor/remote'
- if 902.0 <= freq <= 928.0 and 'FSK' in mod:
- return 'Likely ISM telemetry (FSK/GFSK)'
- if 'PWM' in mod:
- return 'Likely pulse-width/distance keyed remote'
- if 'FSK' in mod:
- return 'Likely continuous-tone telemetry'
- if 'OOK' in mod:
- return 'Likely OOK keyed burst transmitter'
- return 'Unknown protocol family'
+ return "No burst activity"
+ if 433.70 <= freq <= 434.10 and "OOK" in mod and burst_count >= 2:
+ return "Likely weather sensor / simple remote telemetry"
+ if 868.0 <= freq <= 870.0 and "OOK" in mod:
+ return "Likely EU ISM OOK sensor/remote"
+ if 902.0 <= freq <= 928.0 and "FSK" in mod:
+ return "Likely ISM telemetry (FSK/GFSK)"
+ if "PWM" in mod:
+ return "Likely pulse-width/distance keyed remote"
+ if "FSK" in mod:
+ return "Likely continuous-tone telemetry"
+ if "OOK" in mod:
+ return "Likely OOK keyed burst transmitter"
+ return "Unknown protocol family"
def _auto_capture_label(
self,
@@ -610,18 +630,18 @@ class SubGhzManager:
protocol_hint: str,
) -> str:
freq = frequency_hz / 1_000_000
- mod = (modulation_hint or '').upper()
+ mod = (modulation_hint or "").upper()
if burst_count <= 0:
- return f'Raw Capture {freq:.3f} MHz'
- if 'weather' in protocol_hint.lower():
- return f'Weather-like Burst ({burst_count})'
- if 'OOK' in mod:
- return f'OOK Burst Cluster ({burst_count})'
- if 'FSK' in mod:
- return f'FSK Telemetry Burst ({burst_count})'
- if 'PWM' in mod:
- return f'PWM/PPM Burst ({burst_count})'
- return f'RF Burst Capture ({burst_count})'
+ return f"Raw Capture {freq:.3f} MHz"
+ if "weather" in protocol_hint.lower():
+ return f"Weather-like Burst ({burst_count})"
+ if "OOK" in mod:
+ return f"OOK Burst Cluster ({burst_count})"
+ if "FSK" in mod:
+ return f"FSK Telemetry Burst ({burst_count})"
+ if "PWM" in mod:
+ return f"PWM/PPM Burst ({burst_count})"
+ return f"RF Burst Capture ({burst_count})"
def _trim_capture_to_trigger_window(
self,
@@ -634,11 +654,8 @@ class SubGhzManager:
if not self._rx_trigger_enabled or not bursts or sample_rate <= 0:
return duration_seconds, bursts
- first_start = min(float(b.get('start_seconds', 0.0)) for b in bursts)
- last_end = max(
- float(b.get('start_seconds', 0.0)) + float(b.get('duration_seconds', 0.0))
- for b in bursts
- )
+ first_start = min(float(b.get("start_seconds", 0.0)) for b in bursts)
+ last_end = max(float(b.get("start_seconds", 0.0)) + float(b.get("duration_seconds", 0.0)) for b in bursts)
start_s = max(0.0, first_start - self._rx_trigger_pre_s)
end_s = min(duration_seconds, last_end + self._rx_trigger_post_s)
if end_s <= start_s:
@@ -652,9 +669,9 @@ class SubGhzManager:
if end_byte <= start_byte:
return duration_seconds, bursts
- tmp_path = iq_file.with_suffix('.trimtmp')
+ tmp_path = iq_file.with_suffix(".trimtmp")
try:
- with open(iq_file, 'rb') as src, open(tmp_path, 'wb') as dst:
+ with open(iq_file, "rb") as src, open(tmp_path, "wb") as dst:
src.seek(start_byte)
remaining = end_byte - start_byte
while remaining > 0:
@@ -676,14 +693,14 @@ class SubGhzManager:
trimmed_duration = max(0.0, float(end_byte - start_byte) / float(bytes_per_second))
adjusted_bursts: list[dict] = []
for burst in bursts:
- raw_start = float(burst.get('start_seconds', 0.0))
- raw_dur = max(0.0, float(burst.get('duration_seconds', 0.0)))
+ raw_start = float(burst.get("start_seconds", 0.0))
+ raw_dur = max(0.0, float(burst.get("duration_seconds", 0.0)))
raw_end = raw_start + raw_dur
if raw_end < start_s or raw_start > end_s:
continue
adjusted = dict(burst)
- adjusted['start_seconds'] = round(max(0.0, raw_start - start_s), 3)
- adjusted['duration_seconds'] = round(raw_dur, 3)
+ adjusted["start_seconds"] = round(max(0.0, raw_start - start_s), 3)
+ adjusted["duration_seconds"] = round(raw_dur, 3)
adjusted_bursts.append(adjusted)
return trimmed_duration, adjusted_bursts if adjusted_bursts else bursts
@@ -718,7 +735,7 @@ class SubGhzManager:
burst_last_high = 0.0
burst_peak = 0
burst_bytes = bytearray()
- burst_hint_family = 'Unknown'
+ burst_hint_family = "Unknown"
burst_hint_conf = 0.0
BURST_OFF_HOLD = 0.18
BURST_MIN_DURATION = 0.04
@@ -730,11 +747,11 @@ class SubGhzManager:
on_threshold = 0.0
warmup_until = time.time() + 1.0
modulation_scores: dict[str, float] = {
- 'OOK/ASK': 0.0,
- 'FSK/GFSK': 0.0,
- 'PWM/PPM': 0.0,
+ "OOK/ASK": 0.0,
+ "FSK/GFSK": 0.0,
+ "PWM/PPM": 0.0,
}
- last_hint_reason = ''
+ last_hint_reason = ""
try:
fd = file_handle.fileno()
@@ -765,7 +782,7 @@ class SubGhzManager:
if first_chunk:
first_chunk = False
- self._emit({'type': 'info', 'text': '[rx] Receiving IQ data...'})
+ self._emit({"type": "info", "text": "[rx] Receiving IQ data..."})
now = time.time()
if now - last_hint_eval >= HINT_EVAL_INTERVAL:
@@ -799,7 +816,7 @@ class SubGhzManager:
off_threshold = max(0.8, min(on_threshold - 0.5, noise_floor + off_delta))
rising = smooth_level - prev_smooth_level
- self._emit({'type': 'rx_level', 'level': int(round(smooth_level))})
+ self._emit({"type": "rx_level", "level": int(round(smooth_level))})
if not burst_active:
if now >= warmup_until and smooth_level >= on_threshold and rising >= 0.35:
@@ -808,25 +825,25 @@ class SubGhzManager:
burst_last_high = now
burst_peak = int(round(smooth_level))
burst_bytes = bytearray(data[: min(len(data), MAX_BURST_BYTES)])
- burst_hint_family = 'Unknown'
+ burst_hint_family = "Unknown"
burst_hint_conf = 0.0
if self._rx_trigger_enabled and self._rx_trigger_first_burst_start is None:
- self._rx_trigger_first_burst_start = max(
- 0.0, now - self._rx_start_time
+ self._rx_trigger_first_burst_start = max(0.0, now - self._rx_start_time)
+ self._emit(
+ {
+ "type": "info",
+ "text": "[rx] Trigger fired - capturing burst window",
+ }
)
- self._emit({
- 'type': 'info',
- 'text': '[rx] Trigger fired - capturing burst window',
- })
- self._emit({
- 'type': 'rx_burst',
- 'mode': 'rx',
- 'event': 'start',
- 'start_offset_s': round(
- max(0.0, now - self._rx_start_time), 3
- ),
- 'level': int(round(smooth_level)),
- })
+ self._emit(
+ {
+ "type": "rx_burst",
+ "mode": "rx",
+ "event": "start",
+ "start_offset_s": round(max(0.0, now - self._rx_start_time), 3),
+ "level": int(round(smooth_level)),
+ }
+ )
else:
if smooth_level >= off_threshold:
burst_last_high = now
@@ -840,9 +857,7 @@ class SubGhzManager:
duration,
)
if fp:
- self._rx_fingerprint_counts[fp] = (
- self._rx_fingerprint_counts.get(fp, 0) + 1
- )
+ self._rx_fingerprint_counts[fp] = self._rx_fingerprint_counts.get(fp, 0) + 1
burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint(
bytes(burst_bytes)
)
@@ -850,31 +865,29 @@ class SubGhzManager:
modulation_scores[burst_hint_family] += burst_hint_conf * 1.8
last_hint_reason = burst_reason
burst_data = {
- 'start_seconds': round(
- max(0.0, burst_start - self._rx_start_time), 3
- ),
- 'duration_seconds': round(duration, 3),
- 'peak_level': int(burst_peak),
- 'fingerprint': fp,
- 'modulation_hint': burst_hint_family,
- 'modulation_confidence': round(float(burst_hint_conf), 3),
+ "start_seconds": round(max(0.0, burst_start - self._rx_start_time), 3),
+ "duration_seconds": round(duration, 3),
+ "peak_level": int(burst_peak),
+ "fingerprint": fp,
+ "modulation_hint": burst_hint_family,
+ "modulation_confidence": round(float(burst_hint_conf), 3),
}
if len(self._rx_bursts) < 512:
self._rx_bursts.append(burst_data)
- self._rx_trigger_last_burst_end = max(
- 0.0, now - self._rx_start_time
+ self._rx_trigger_last_burst_end = max(0.0, now - self._rx_start_time)
+ self._emit(
+ {
+ "type": "rx_burst",
+ "mode": "rx",
+ "event": "end",
+ "start_offset_s": burst_data["start_seconds"],
+ "duration_ms": int(duration * 1000),
+ "peak_level": int(burst_peak),
+ "fingerprint": fp,
+ "modulation_hint": burst_hint_family,
+ "modulation_confidence": round(float(burst_hint_conf), 3),
+ }
)
- self._emit({
- 'type': 'rx_burst',
- 'mode': 'rx',
- 'event': 'end',
- 'start_offset_s': burst_data['start_seconds'],
- 'duration_ms': int(duration * 1000),
- 'peak_level': int(burst_peak),
- 'fingerprint': fp,
- 'modulation_hint': burst_hint_family,
- 'modulation_confidence': round(float(burst_hint_conf), 3),
- })
burst_active = False
burst_peak = 0
burst_bytes = bytearray()
@@ -888,20 +901,22 @@ class SubGhzManager:
hint_conf = 0.0 if total_score <= 0 else min(0.98, best_score / total_score)
protocol_hint = self._protocol_hint_from_capture(
self._rx_frequency_hz,
- best_family if hint_conf >= 0.3 else 'Unknown',
+ best_family if hint_conf >= 0.3 else "Unknown",
len(self._rx_bursts),
)
self._rx_protocol_hint = protocol_hint
if hint_conf >= 0.30:
self._rx_modulation_hint = best_family
self._rx_modulation_confidence = hint_conf
- self._emit({
- 'type': 'rx_hint',
- 'modulation_hint': best_family,
- 'confidence': round(hint_conf, 3),
- 'protocol_hint': protocol_hint,
- 'reason': last_hint_reason,
- })
+ self._emit(
+ {
+ "type": "rx_hint",
+ "modulation_hint": best_family,
+ "confidence": round(hint_conf, 3),
+ "protocol_hint": protocol_hint,
+ "reason": last_hint_reason,
+ }
+ )
last_hint_emit = now
# Smart-trigger auto-stop after quiet post-roll window.
@@ -912,25 +927,30 @@ class SubGhzManager:
and not self._rx_autostop_pending
):
last_end = self._rx_trigger_last_burst_end
- if last_end is not None and (max(0.0, now - self._rx_start_time) - last_end) >= self._rx_trigger_post_s:
+ if (
+ last_end is not None
+ and (max(0.0, now - self._rx_start_time) - last_end) >= self._rx_trigger_post_s
+ ):
self._rx_autostop_pending = True
- self._emit({
- 'type': 'info',
- 'text': '[rx] Trigger window complete - finalizing capture',
- })
+ self._emit(
+ {
+ "type": "info",
+ "text": "[rx] Trigger window complete - finalizing capture",
+ }
+ )
threading.Thread(target=self.stop_receive, daemon=True).start()
break
if now - last_wave >= WAVE_INTERVAL:
samples = self._extract_waveform(data)
if samples:
- self._emit({'type': 'rx_waveform', 'samples': samples})
+ self._emit({"type": "rx_waveform", "samples": samples})
last_wave = now
if now - last_spectrum >= SPECTRUM_INTERVAL:
bins = self._compute_rx_spectrum(data)
if bins:
- self._emit({'type': 'rx_spectrum', 'bins': bins})
+ self._emit({"type": "rx_spectrum", "bins": bins})
last_spectrum = now
if now - last_stats >= STATS_INTERVAL:
@@ -941,20 +961,26 @@ class SubGhzManager:
file_size = self._rx_file.stat().st_size
except OSError:
file_size = 0
- self._emit({
- 'type': 'rx_stats',
- 'rate_kb': round(rate_kb, 1),
- 'file_size': file_size,
- 'elapsed_seconds': round(time.time() - self._rx_start_time, 1) if self._rx_start_time else 0,
- })
+ self._emit(
+ {
+ "type": "rx_stats",
+ "rate_kb": round(rate_kb, 1),
+ "file_size": file_size,
+ "elapsed_seconds": round(time.time() - self._rx_start_time, 1)
+ if self._rx_start_time
+ else 0,
+ }
+ )
if now - last_log >= 5.0:
- self._emit({
- 'type': 'info',
- 'text': (
- f'[rx] IQ: {rate_kb:.0f} KB/s '
- f'(lvl {smooth_level:.1f}, floor {noise_floor:.1f}, thr {on_threshold:.1f})'
- ),
- })
+ self._emit(
+ {
+ "type": "info",
+ "text": (
+ f"[rx] IQ: {rate_kb:.0f} KB/s "
+ f"(lvl {smooth_level:.1f}, floor {noise_floor:.1f}, thr {on_threshold:.1f})"
+ ),
+ }
+ )
last_log = now
bytes_since_stats = 0
last_stats = now
@@ -968,9 +994,7 @@ class SubGhzManager:
duration,
)
if fp:
- self._rx_fingerprint_counts[fp] = (
- self._rx_fingerprint_counts.get(fp, 0) + 1
- )
+ self._rx_fingerprint_counts[fp] = self._rx_fingerprint_counts.get(fp, 0) + 1
burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint(
bytes(burst_bytes)
)
@@ -978,31 +1002,29 @@ class SubGhzManager:
modulation_scores[burst_hint_family] += burst_hint_conf * 1.8
last_hint_reason = burst_reason
burst_data = {
- 'start_seconds': round(
- max(0.0, burst_start - self._rx_start_time), 3
- ),
- 'duration_seconds': round(duration, 3),
- 'peak_level': int(burst_peak),
- 'fingerprint': fp,
- 'modulation_hint': burst_hint_family,
- 'modulation_confidence': round(float(burst_hint_conf), 3),
+ "start_seconds": round(max(0.0, burst_start - self._rx_start_time), 3),
+ "duration_seconds": round(duration, 3),
+ "peak_level": int(burst_peak),
+ "fingerprint": fp,
+ "modulation_hint": burst_hint_family,
+ "modulation_confidence": round(float(burst_hint_conf), 3),
}
if len(self._rx_bursts) < 512:
self._rx_bursts.append(burst_data)
- self._rx_trigger_last_burst_end = max(
- 0.0, time.time() - self._rx_start_time
+ self._rx_trigger_last_burst_end = max(0.0, time.time() - self._rx_start_time)
+ self._emit(
+ {
+ "type": "rx_burst",
+ "mode": "rx",
+ "event": "end",
+ "start_offset_s": burst_data["start_seconds"],
+ "duration_ms": int(duration * 1000),
+ "peak_level": int(burst_peak),
+ "fingerprint": fp,
+ "modulation_hint": burst_hint_family,
+ "modulation_confidence": round(float(burst_hint_conf), 3),
+ }
)
- self._emit({
- 'type': 'rx_burst',
- 'mode': 'rx',
- 'event': 'end',
- 'start_offset_s': burst_data['start_seconds'],
- 'duration_ms': int(duration * 1000),
- 'peak_level': int(burst_peak),
- 'fingerprint': fp,
- 'modulation_hint': burst_hint_family,
- 'modulation_confidence': round(float(burst_hint_conf), 3),
- })
# Finalize modulation summary for capture metadata.
if modulation_scores:
@@ -1126,12 +1148,12 @@ class SubGhzManager:
if not process or not process.stderr:
return
try:
- for line in iter(process.stderr.readline, b''):
- text = line.decode('utf-8', errors='replace').strip()
+ for line in iter(process.stderr.readline, b""):
+ text = line.decode("utf-8", errors="replace").strip()
if text:
logger.debug(f"[hackrf_rx] {text}")
- if 'error' in text.lower():
- self._emit({'type': 'info', 'text': f'[hackrf_rx] {text}'})
+ if "error" in text.lower():
+ self._emit({"type": "info", "text": f"[hackrf_rx] {text}"})
except Exception:
pass
@@ -1141,7 +1163,7 @@ class SubGhzManager:
proc_to_terminate: subprocess.Popen | None = None
with self._lock:
if not self._rx_process or self._rx_process.poll() is not None:
- return {'status': 'not_running'}
+ return {"status": "not_running"}
self._rx_stop = True
thread_to_join = self._rx_thread
@@ -1179,7 +1201,7 @@ class SubGhzManager:
bursts=bursts,
)
size = iq_file.stat().st_size
- dominant_fingerprint = ''
+ dominant_fingerprint = ""
dominant_fingerprint_count = 0
for fp, count in self._rx_fingerprint_counts.items():
if count > dominant_fingerprint_count:
@@ -1191,9 +1213,9 @@ class SubGhzManager:
if not modulation_hint and bursts:
burst_hint_totals: dict[str, float] = {}
for burst in bursts:
- hint_name = str(burst.get('modulation_hint') or '').strip()
- hint_conf = float(burst.get('modulation_confidence') or 0.0)
- if not hint_name or hint_name.lower() == 'unknown':
+ hint_name = str(burst.get("modulation_hint") or "").strip()
+ hint_conf = float(burst.get("modulation_confidence") or 0.0)
+ if not hint_name or hint_name.lower() == "unknown":
continue
burst_hint_totals[hint_name] = burst_hint_totals.get(hint_name, 0.0) + max(0.05, hint_conf)
if burst_hint_totals:
@@ -1227,7 +1249,7 @@ class SubGhzManager:
duration_seconds=round(duration, 1),
size_bytes=size,
label=label,
- label_source='auto',
+ label_source="auto",
bursts=bursts,
modulation_hint=modulation_hint,
modulation_confidence=round(modulation_confidence, 3),
@@ -1237,7 +1259,7 @@ class SubGhzManager:
trigger_pre_seconds=round(self._rx_trigger_pre_s, 3),
trigger_post_seconds=round(self._rx_trigger_post_s, 3),
)
- meta_path = iq_file.with_suffix('.json')
+ meta_path = iq_file.with_suffix(".json")
try:
meta_path.write_text(json.dumps(capture.to_dict(), indent=2))
except OSError as e:
@@ -1252,101 +1274,186 @@ class SubGhzManager:
self._rx_trigger_first_burst_start = None
self._rx_trigger_last_burst_end = None
self._rx_autostop_pending = False
- self._rx_modulation_hint = ''
+ self._rx_modulation_hint = ""
self._rx_modulation_confidence = 0.0
- self._rx_protocol_hint = ''
+ self._rx_protocol_hint = ""
self._rx_fingerprint_counts = {}
- self._emit({
- 'type': 'status',
- 'mode': 'idle',
- 'status': 'stopped',
- 'duration_seconds': round(duration, 1),
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "idle",
+ "status": "stopped",
+ "duration_seconds": round(duration, 1),
+ }
+ )
- result = {'status': 'stopped', 'duration_seconds': round(duration, 1)}
+ result = {"status": "stopped", "duration_seconds": round(duration, 1)}
if capture:
- result['capture'] = capture.to_dict()
+ result["capture"] = capture.to_dict()
return result
# ------------------------------------------------------------------
# DECODE (hackrf_transfer piped to rtl_433)
# ------------------------------------------------------------------
- def start_decode(
- self,
- frequency_hz: int,
- sample_rate: int = 2_000_000,
+ def start_decode(
+ self,
+ frequency_hz: int,
+ sample_rate: int = 2_000_000,
lna_gain: int = 32,
vga_gain: int = 20,
- decode_profile: str = 'weather',
- device_serial: str | None = None,
- ) -> dict:
- # Pre-lock: tool availability & device detection (blocking I/O)
- hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
- if not hackrf_transfer_path:
- return {'status': 'error', 'message': 'hackrf_transfer not found'}
- rtl433_path = self._resolve_tool('rtl_433')
- if not rtl433_path:
- return {'status': 'error', 'message': 'rtl_433 not found'}
- device_err = self._require_hackrf_device()
- if device_err:
- return {'status': 'error', 'message': device_err}
+ decode_profile: str = "weather",
+ device_serial: str | None = None,
+ ) -> dict:
+ # Pre-lock: tool availability & device detection (blocking I/O)
+ hackrf_transfer_path = self._resolve_tool("hackrf_transfer")
+ if not hackrf_transfer_path:
+ return {"status": "error", "message": "hackrf_transfer not found"}
+ rtl433_path = self._resolve_tool("rtl_433")
+ if not rtl433_path:
+ return {"status": "error", "message": "rtl_433 not found"}
+ device_err = self._require_hackrf_device()
+ if device_err:
+ return {"status": "error", "message": device_err}
with self._lock:
- if self.active_mode != 'idle':
- return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
+ if self.active_mode != "idle":
+ return {"status": "error", "message": f"Already running: {self.active_mode}"}
# Keep decode bandwidth conservative for stability. 2 Msps is enough
# for common SubGHz protocols while staying within HackRF support.
requested_sample_rate = int(sample_rate)
stable_sample_rate = max(2_000_000, min(2_000_000, requested_sample_rate))
- # Build hackrf_transfer command (producer: raw IQ to stdout)
- hackrf_cmd = [
- hackrf_transfer_path,
- '-r', '-',
- '-f', str(frequency_hz),
- '-s', str(stable_sample_rate),
- '-l', str(max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))),
- '-g', str(max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))),
+ # Build hackrf_transfer command (producer: raw IQ to stdout)
+ hackrf_cmd = [
+ hackrf_transfer_path,
+ "-r",
+ "-",
+ "-f",
+ str(frequency_hz),
+ "-s",
+ str(stable_sample_rate),
+ "-l",
+ str(max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))),
+ "-g",
+ str(max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))),
]
if device_serial:
- hackrf_cmd.extend(['-d', device_serial])
+ hackrf_cmd.extend(["-d", device_serial])
- # Build rtl_433 command (consumer: reads IQ from stdin)
- # Feed signed 8-bit complex IQ directly from hackrf_transfer.
- rtl433_cmd = [
- rtl433_path,
- '-r', 'cs8:-',
- '-s', str(stable_sample_rate),
- '-f', str(frequency_hz),
- '-F', 'json',
- '-F', 'log',
- '-M', 'level',
- '-M', 'noise:5',
- '-Y', 'autolevel',
- '-Y', 'ampest',
- '-Y', 'minsnr=2.5',
+ # Build rtl_433 command (consumer: reads IQ from stdin)
+ # Feed signed 8-bit complex IQ directly from hackrf_transfer.
+ rtl433_cmd = [
+ rtl433_path,
+ "-r",
+ "cs8:-",
+ "-s",
+ str(stable_sample_rate),
+ "-f",
+ str(frequency_hz),
+ "-F",
+ "json",
+ "-F",
+ "log",
+ "-M",
+ "level",
+ "-M",
+ "noise:5",
+ "-Y",
+ "autolevel",
+ "-Y",
+ "ampest",
+ "-Y",
+ "minsnr=2.5",
]
- profile = (decode_profile or 'weather').strip().lower()
- if profile == 'weather':
+ profile = (decode_profile or "weather").strip().lower()
+ if profile == "weather":
# Limit decoder set to weather/temperature/humidity/rain/wind
# protocols for better sensitivity and lower CPU load.
weather_protocol_ids = [
- 2, 3, 8, 12, 16, 18, 19, 20, 31, 32, 34, 40, 47, 50, 52,
- 54, 55, 56, 57, 69, 73, 74, 75, 76, 78, 79, 85, 91, 92,
- 108, 109, 111, 112, 113, 119, 120, 124, 127, 132, 133,
- 134, 138, 141, 143, 144, 145, 146, 147, 152, 153, 157,
- 158, 163, 165, 166, 170, 171, 172, 173, 175, 182, 183,
- 184, 194, 195, 196, 205, 206, 213, 214, 215, 217, 219,
- 221, 222,
+ 2,
+ 3,
+ 8,
+ 12,
+ 16,
+ 18,
+ 19,
+ 20,
+ 31,
+ 32,
+ 34,
+ 40,
+ 47,
+ 50,
+ 52,
+ 54,
+ 55,
+ 56,
+ 57,
+ 69,
+ 73,
+ 74,
+ 75,
+ 76,
+ 78,
+ 79,
+ 85,
+ 91,
+ 92,
+ 108,
+ 109,
+ 111,
+ 112,
+ 113,
+ 119,
+ 120,
+ 124,
+ 127,
+ 132,
+ 133,
+ 134,
+ 138,
+ 141,
+ 143,
+ 144,
+ 145,
+ 146,
+ 147,
+ 152,
+ 153,
+ 157,
+ 158,
+ 163,
+ 165,
+ 166,
+ 170,
+ 171,
+ 172,
+ 173,
+ 175,
+ 182,
+ 183,
+ 184,
+ 194,
+ 195,
+ 196,
+ 205,
+ 206,
+ 213,
+ 214,
+ 215,
+ 217,
+ 219,
+ 221,
+ 222,
]
- rtl433_cmd.extend(['-R', '0'])
+ rtl433_cmd.extend(["-R", "0"])
for proto_id in weather_protocol_ids:
- rtl433_cmd.extend(['-R', str(proto_id)])
+ rtl433_cmd.extend(["-R", str(proto_id)])
else:
- profile = 'all'
+ profile = "all"
logger.info(f"SubGHz decode: {' '.join(hackrf_cmd)} | {' '.join(rtl433_cmd)}")
@@ -1377,15 +1484,17 @@ class SubGhzManager:
self._decode_frequency_hz = frequency_hz
self._decode_sample_rate = stable_sample_rate
self._decode_stop = False
- self._emit({'type': 'info', 'text': f'[decode] Profile: {profile}'})
+ self._emit({"type": "info", "text": f"[decode] Profile: {profile}"})
if requested_sample_rate != stable_sample_rate:
- self._emit({
- 'type': 'info',
- 'text': (
- f'[decode] Using {stable_sample_rate} sps '
- f'(requested {requested_sample_rate}) for stable live decode'
- ),
- })
+ self._emit(
+ {
+ "type": "info",
+ "text": (
+ f"[decode] Using {stable_sample_rate} sps "
+ f"(requested {requested_sample_rate}) for stable live decode"
+ ),
+ }
+ )
# Buffered relay: hackrf stdout → queue → rtl_433 stdin
# with auto-restart when HackRF USB disconnects.
@@ -1420,18 +1529,20 @@ class SubGhzManager:
daemon=True,
).start()
- self._emit({
- 'type': 'status',
- 'mode': 'decode',
- 'status': 'started',
- 'frequency_hz': frequency_hz,
- 'sample_rate': stable_sample_rate,
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "decode",
+ "status": "started",
+ "frequency_hz": frequency_hz,
+ "sample_rate": stable_sample_rate,
+ }
+ )
return {
- 'status': 'started',
- 'frequency_hz': frequency_hz,
- 'sample_rate': stable_sample_rate,
+ "status": "started",
+ "frequency_hz": frequency_hz,
+ "sample_rate": stable_sample_rate,
}
except FileNotFoundError as e:
@@ -1439,7 +1550,7 @@ class SubGhzManager:
safe_terminate(self._decode_hackrf_process)
unregister_process(self._decode_hackrf_process)
self._decode_hackrf_process = None
- return {'status': 'error', 'message': f'Tool not found: {e.filename or "unknown"}'}
+ return {"status": "error", "message": f"Tool not found: {e.filename or 'unknown'}"}
except Exception as e:
for proc in (self._decode_hackrf_process, self._decode_process):
if proc:
@@ -1448,7 +1559,7 @@ class SubGhzManager:
self._decode_hackrf_process = None
self._decode_process = None
logger.error(f"Failed to start decode: {e}")
- return {'status': 'error', 'message': str(e)}
+ return {"status": "error", "message": str(e)}
def _hackrf_reader(
self,
@@ -1465,9 +1576,9 @@ class SubGhzManager:
Uses os.read() on the raw fd to drain the pipe immediately (no Python
buffering), minimising backpressure on the USB transfer path.
"""
- CHUNK = 65536 # 64 KB read size for lower latency
- RESTART_DELAY = 0.15 # seconds before restart attempt
- MAX_RESTARTS = 3600 # allow longer sessions
+ CHUNK = 65536 # 64 KB read size for lower latency
+ RESTART_DELAY = 0.15 # seconds before restart attempt
+ MAX_RESTARTS = 3600 # allow longer sessions
MAX_QUICK_RESTARTS = 6
QUICK_RESTART_WINDOW = 20.0
@@ -1487,7 +1598,7 @@ class SubGhzManager:
if not src or (hackrf_proc and hackrf_proc.poll() is not None):
if restarts >= MAX_RESTARTS:
logger.error("hackrf_transfer: max restarts reached")
- self._emit({'type': 'error', 'message': 'HackRF: max restarts reached'})
+ self._emit({"type": "error", "message": "HackRF: max restarts reached"})
break
# Unregister the dead process before restarting
@@ -1522,16 +1633,18 @@ class SubGhzManager:
restart_times.append(now)
restart_times = [t for t in restart_times if (now - t) <= QUICK_RESTART_WINDOW]
if len(restart_times) >= MAX_QUICK_RESTARTS:
- self._emit({
- 'type': 'error',
- 'message': (
- 'HackRF stream is unstable (restarting repeatedly). '
- 'Try lower gain/sample-rate or reconnect the device.'
- ),
- })
+ self._emit(
+ {
+ "type": "error",
+ "message": (
+ "HackRF stream is unstable (restarting repeatedly). "
+ "Try lower gain/sample-rate or reconnect the device."
+ ),
+ }
+ )
break
logger.info(f"hackrf_transfer restarted ({restarts})")
- self._emit({'type': 'info', 'text': f'[decode] HackRF stream restarted ({restarts})'})
+ self._emit({"type": "info", "text": f"[decode] HackRF stream restarted ({restarts})"})
threading.Thread(
target=self._monitor_decode_hackrf_stderr,
args=(hackrf_proc,),
@@ -1539,10 +1652,12 @@ class SubGhzManager:
).start()
except Exception as e:
logger.error(f"Failed to restart hackrf_transfer: {e}")
- self._emit({
- 'type': 'error',
- 'message': f'Failed to restart hackrf_transfer: {e}',
- })
+ self._emit(
+ {
+ "type": "error",
+ "message": f"Failed to restart hackrf_transfer: {e}",
+ }
+ )
break
if not src:
@@ -1565,11 +1680,11 @@ class SubGhzManager:
data = os.read(fd, CHUNK)
if not data:
if hackrf_proc and hackrf_proc.poll() is not None:
- self._emit({'type': 'info', 'text': '[decode] HackRF stream stopped'})
+ self._emit({"type": "info", "text": "[decode] HackRF stream stopped"})
break
if first_chunk:
first_chunk = False
- self._emit({'type': 'info', 'text': '[decode] IQ source active'})
+ self._emit({"type": "info", "text": "[decode] IQ source active"})
try:
iq_queue.put_nowait(data)
except queue.Full:
@@ -1631,37 +1746,37 @@ class SubGhzManager:
if now - last_level >= LEVEL_INTERVAL:
level = self._compute_rx_level(data)
- self._emit({'type': 'decode_level', 'level': level})
+ self._emit({"type": "decode_level", "level": level})
if level >= BURST_ON_LEVEL:
burst_last_high = now
if not burst_active:
burst_active = True
burst_start = now
burst_peak = level
- self._emit({
- 'type': 'rx_burst',
- 'mode': 'decode',
- 'event': 'start',
- 'start_offset_s': round(
- max(0.0, now - self._decode_start_time), 3
- ),
- 'level': int(level),
- })
+ self._emit(
+ {
+ "type": "rx_burst",
+ "mode": "decode",
+ "event": "start",
+ "start_offset_s": round(max(0.0, now - self._decode_start_time), 3),
+ "level": int(level),
+ }
+ )
else:
burst_peak = max(burst_peak, level)
elif burst_active and (now - burst_last_high) >= BURST_OFF_HOLD:
duration = now - burst_start
if duration >= BURST_MIN_DURATION:
- self._emit({
- 'type': 'rx_burst',
- 'mode': 'decode',
- 'event': 'end',
- 'start_offset_s': round(
- max(0.0, burst_start - self._decode_start_time), 3
- ),
- 'duration_ms': int(duration * 1000),
- 'peak_level': int(burst_peak),
- })
+ self._emit(
+ {
+ "type": "rx_burst",
+ "mode": "decode",
+ "event": "end",
+ "start_offset_s": round(max(0.0, burst_start - self._decode_start_time), 3),
+ "duration_ms": int(duration * 1000),
+ "peak_level": int(burst_peak),
+ }
+ )
burst_active = False
burst_peak = 0
last_level = now
@@ -1669,13 +1784,13 @@ class SubGhzManager:
if now - last_wave >= WAVE_INTERVAL:
samples = self._extract_waveform(data, points=160)
if samples:
- self._emit({'type': 'decode_waveform', 'samples': samples})
+ self._emit({"type": "decode_waveform", "samples": samples})
last_wave = now
if now - last_spectrum >= SPECTRUM_INTERVAL:
bins = self._compute_rx_spectrum(data, bins=128)
if bins:
- self._emit({'type': 'decode_spectrum', 'bins': bins})
+ self._emit({"type": "decode_spectrum", "bins": bins})
last_spectrum = now
# Pass HackRF cs8 IQ bytes through directly.
@@ -1688,45 +1803,51 @@ class SubGhzManager:
if first_chunk:
first_chunk = False
logger.info(f"IQ data flowing to rtl_433 ({len(data)} bytes)")
- self._emit({
- 'type': 'info',
- 'text': '[decode] Receiving IQ data from HackRF...',
- })
+ self._emit(
+ {
+ "type": "info",
+ "text": "[decode] Receiving IQ data from HackRF...",
+ }
+ )
elapsed = now - last_stats
if elapsed >= STATS_INTERVAL:
rate_kb = bytes_since_stats / elapsed / 1024
- self._emit({
- 'type': 'info',
- 'text': f'[decode] IQ: {rate_kb:.0f} KB/s — listening for signals...',
- })
- self._emit({
- 'type': 'decode_raw',
- 'text': f'IQ stream active: {rate_kb:.0f} KB/s',
- })
+ self._emit(
+ {
+ "type": "info",
+ "text": f"[decode] IQ: {rate_kb:.0f} KB/s — listening for signals...",
+ }
+ )
+ self._emit(
+ {
+ "type": "decode_raw",
+ "text": f"IQ stream active: {rate_kb:.0f} KB/s",
+ }
+ )
bytes_since_stats = 0
last_stats = now
except (BrokenPipeError, OSError) as e:
logger.debug(f"rtl_433 writer pipe closed: {e}")
- self._emit({'type': 'info', 'text': f'[decode] Writer pipe closed: {e}'})
+ self._emit({"type": "info", "text": f"[decode] Writer pipe closed: {e}"})
except Exception as e:
logger.error(f"rtl_433 writer error: {e}")
- self._emit({'type': 'error', 'message': f'Decode writer error: {e}'})
+ self._emit({"type": "error", "message": f"Decode writer error: {e}"})
finally:
if burst_active:
duration = max(0.0, time.time() - burst_start)
if duration >= BURST_MIN_DURATION:
- self._emit({
- 'type': 'rx_burst',
- 'mode': 'decode',
- 'event': 'end',
- 'start_offset_s': round(
- max(0.0, burst_start - self._decode_start_time), 3
- ),
- 'duration_ms': int(duration * 1000),
- 'peak_level': int(burst_peak),
- })
+ self._emit(
+ {
+ "type": "rx_burst",
+ "mode": "decode",
+ "event": "end",
+ "start_offset_s": round(max(0.0, burst_start - self._decode_start_time), 3),
+ "duration_ms": int(duration * 1000),
+ "peak_level": int(burst_peak),
+ }
+ )
with contextlib.suppress(OSError):
dst.close()
@@ -1736,8 +1857,8 @@ class SubGhzManager:
return
got_output = False
try:
- for line in iter(process.stdout.readline, b''):
- text = line.decode('utf-8', errors='replace').strip()
+ for line in iter(process.stdout.readline, b""):
+ text = line.decode("utf-8", errors="replace").strip()
if not text:
continue
if not got_output:
@@ -1745,10 +1866,10 @@ class SubGhzManager:
logger.info("rtl_433 producing output")
try:
data = json.loads(text)
- data['type'] = 'decode'
+ data["type"] = "decode"
self._emit(data)
except json.JSONDecodeError:
- self._emit({'type': 'decode_raw', 'text': text})
+ self._emit({"type": "decode_raw", "text": text})
except Exception as e:
logger.error(f"Error reading decode output: {e}")
finally:
@@ -1756,62 +1877,62 @@ class SubGhzManager:
unregister_process(process)
if rc is not None and rc != 0 and rc != -15:
logger.warning(f"rtl_433 exited with code {rc}")
- self._emit({
- 'type': 'info',
- 'text': f'[rtl_433] Exited with code {rc}',
- })
+ self._emit(
+ {
+ "type": "info",
+ "text": f"[rtl_433] Exited with code {rc}",
+ }
+ )
with self._lock:
if self._decode_process is process:
self._decode_process = None
self._decode_frequency_hz = 0
self._decode_sample_rate = 0
self._decode_start_time = 0
- self._emit({
- 'type': 'status',
- 'mode': 'idle',
- 'status': 'decode_stopped',
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "idle",
+ "status": "decode_stopped",
+ }
+ )
def _monitor_decode_hackrf_stderr(self, process: subprocess.Popen) -> None:
if not process or not process.stderr:
return
fatal_disconnect_emitted = False
try:
- for line in iter(process.stderr.readline, b''):
- text = line.decode('utf-8', errors='replace').strip()
+ for line in iter(process.stderr.readline, b""):
+ text = line.decode("utf-8", errors="replace").strip()
if not text:
continue
logger.debug(f"[hackrf_decode] {text}")
lower = text.lower()
- if (
- not fatal_disconnect_emitted
- and (
- 'no such device' in lower
- or 'device not found' in lower
- or 'disconnected' in lower
- )
+ if not fatal_disconnect_emitted and (
+ "no such device" in lower or "device not found" in lower or "disconnected" in lower
):
fatal_disconnect_emitted = True
self._hackrf_device_cache = False
self._hackrf_device_cache_ts = time.time()
self._decode_stop = True
- self._emit({
- 'type': 'error',
- 'message': (
- 'HackRF disconnected during decode. '
- 'Reconnect the device, then press Start again.'
- ),
- })
+ self._emit(
+ {
+ "type": "error",
+ "message": (
+ "HackRF disconnected during decode. Reconnect the device, then press Start again."
+ ),
+ }
+ )
if (
- 'error' in lower
- or 'usb' in lower
- or 'overflow' in lower
- or 'underflow' in lower
- or 'failed' in lower
- or 'couldn' in lower
- or 'transfer' in lower
+ "error" in lower
+ or "usb" in lower
+ or "overflow" in lower
+ or "underflow" in lower
+ or "failed" in lower
+ or "couldn" in lower
+ or "transfer" in lower
):
- self._emit({'type': 'info', 'text': f'[hackrf] {text}'})
+ self._emit({"type": "info", "text": f"[hackrf] {text}"})
except Exception:
pass
@@ -1820,18 +1941,28 @@ class SubGhzManager:
if not process or not process.stderr:
return
decode_keywords = (
- 'pulse', 'sync', 'message', 'decoded', 'snr', 'rssi',
- 'level', 'modulation', 'bitbuffer', 'symbol', 'short',
- 'noise', 'detected',
+ "pulse",
+ "sync",
+ "message",
+ "decoded",
+ "snr",
+ "rssi",
+ "level",
+ "modulation",
+ "bitbuffer",
+ "symbol",
+ "short",
+ "noise",
+ "detected",
)
try:
- for line in iter(process.stderr.readline, b''):
- text = line.decode('utf-8', errors='replace').strip()
+ for line in iter(process.stderr.readline, b""):
+ text = line.decode("utf-8", errors="replace").strip()
if text:
logger.debug(f"[rtl_433] {text}")
- self._emit({'type': 'info', 'text': f'[rtl_433] {text}'})
+ self._emit({"type": "info", "text": f"[rtl_433] {text}"})
if any(k in text.lower() for k in decode_keywords):
- self._emit({'type': 'decode_raw', 'text': text})
+ self._emit({"type": "decode_raw", "text": text})
except Exception:
pass
@@ -1840,17 +1971,11 @@ class SubGhzManager:
rtl433_proc: subprocess.Popen | None = None
with self._lock:
- hackrf_running = (
- self._decode_hackrf_process
- and self._decode_hackrf_process.poll() is None
- )
- rtl433_running = (
- self._decode_process
- and self._decode_process.poll() is None
- )
+ hackrf_running = self._decode_hackrf_process and self._decode_hackrf_process.poll() is None
+ rtl433_running = self._decode_process and self._decode_process.poll() is None
if not hackrf_running and not rtl433_running:
- return {'status': 'not_running'}
+ return {"status": "not_running"}
# Signal reader thread to stop before killing processes,
# preventing it from spawning a new hackrf_transfer during cleanup.
@@ -1885,13 +2010,15 @@ class SubGhzManager:
safe_terminate(race_proc)
unregister_process(race_proc)
- self._emit({
- 'type': 'status',
- 'mode': 'idle',
- 'status': 'stopped',
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "idle",
+ "status": "stopped",
+ }
+ )
- return {'status': 'stopped'}
+ return {"status": "stopped"}
# ------------------------------------------------------------------
# TRANSMIT (replay via hackrf_transfer -t)
@@ -1907,10 +2034,8 @@ class SubGhzManager:
for band_low, band_high in SUBGHZ_TX_ALLOWED_BANDS:
if band_low <= freq_mhz <= band_high:
return None
- bands_str = ', '.join(
- f'{lo}-{hi} MHz' for lo, hi in SUBGHZ_TX_ALLOWED_BANDS
- )
- return f'Frequency {freq_mhz:.3f} MHz is outside allowed TX bands: {bands_str}'
+ bands_str = ", ".join(f"{lo}-{hi} MHz" for lo, hi in SUBGHZ_TX_ALLOWED_BANDS)
+ return f"Frequency {freq_mhz:.3f} MHz is outside allowed TX bands: {bands_str}"
@staticmethod
def _estimate_capture_duration_seconds(capture: SubGhzCapture, file_size: int) -> float:
@@ -1931,38 +2056,38 @@ class SubGhzManager:
except OSError as exc:
logger.debug(f"Failed to remove TX temp file {path}: {exc}")
- def transmit(
- self,
- capture_id: str,
- tx_gain: int = 20,
+ def transmit(
+ self,
+ capture_id: str,
+ tx_gain: int = 20,
max_duration: int = 10,
start_seconds: float | None = None,
- duration_seconds: float | None = None,
- device_serial: str | None = None,
- ) -> dict:
- # Pre-lock: tool availability & device detection (blocking I/O)
- hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
- if not hackrf_transfer_path:
- return {'status': 'error', 'message': 'hackrf_transfer not found'}
- device_err = self._require_hackrf_device()
- if device_err:
- return {'status': 'error', 'message': device_err}
+ duration_seconds: float | None = None,
+ device_serial: str | None = None,
+ ) -> dict:
+ # Pre-lock: tool availability & device detection (blocking I/O)
+ hackrf_transfer_path = self._resolve_tool("hackrf_transfer")
+ if not hackrf_transfer_path:
+ return {"status": "error", "message": "hackrf_transfer not found"}
+ device_err = self._require_hackrf_device()
+ if device_err:
+ return {"status": "error", "message": device_err}
# Pre-lock: capture lookup, validation, and segment I/O (can be large)
capture = self._load_capture(capture_id)
if not capture:
- return {'status': 'error', 'message': f'Capture not found: {capture_id}'}
+ return {"status": "error", "message": f"Capture not found: {capture_id}"}
freq_error = self.validate_tx_frequency(capture.frequency_hz)
if freq_error:
- return {'status': 'error', 'message': freq_error}
+ return {"status": "error", "message": freq_error}
tx_gain = max(SUBGHZ_TX_VGA_GAIN_MIN, min(SUBGHZ_TX_VGA_GAIN_MAX, tx_gain))
max_duration = max(1, min(SUBGHZ_TX_MAX_DURATION, max_duration))
iq_path = self._captures_dir / capture.filename
if not iq_path.exists():
- return {'status': 'error', 'message': 'IQ file missing'}
+ return {"status": "error", "message": "IQ file missing"}
# Build segment file outside lock (potentially megabytes of read/write)
tx_path = iq_path
@@ -1972,37 +2097,37 @@ class SubGhzManager:
try:
start_s = max(0.0, float(start_seconds or 0.0))
except (TypeError, ValueError):
- return {'status': 'error', 'message': 'Invalid start_seconds'}
+ return {"status": "error", "message": "Invalid start_seconds"}
try:
seg_s = None if duration_seconds is None else float(duration_seconds)
except (TypeError, ValueError):
- return {'status': 'error', 'message': 'Invalid duration_seconds'}
+ return {"status": "error", "message": "Invalid duration_seconds"}
if seg_s is not None and seg_s <= 0:
- return {'status': 'error', 'message': 'duration_seconds must be greater than 0'}
+ return {"status": "error", "message": "duration_seconds must be greater than 0"}
file_size = iq_path.stat().st_size
total_duration = self._estimate_capture_duration_seconds(capture, file_size)
if total_duration <= 0:
- return {'status': 'error', 'message': 'Unable to determine capture duration for segment TX'}
+ return {"status": "error", "message": "Unable to determine capture duration for segment TX"}
if start_s >= total_duration:
- return {'status': 'error', 'message': 'start_seconds is beyond end of capture'}
+ return {"status": "error", "message": "start_seconds is beyond end of capture"}
end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s)
if end_s <= start_s:
- return {'status': 'error', 'message': 'Selected segment is empty'}
+ return {"status": "error", "message": "Selected segment is empty"}
bytes_per_second = max(2, int(capture.sample_rate) * 2)
start_byte = int(start_s * bytes_per_second) & ~1
end_byte = int(end_s * bytes_per_second) & ~1
if end_byte <= start_byte:
- return {'status': 'error', 'message': 'Selected segment is too short'}
+ return {"status": "error", "message": "Selected segment is too short"}
segment_size = end_byte - start_byte
segment_name = f".txseg_{capture.capture_id}_{uuid.uuid4().hex[:8]}.iq"
segment_path = self._captures_dir / segment_name
segment_path_for_cleanup = segment_path
try:
- with open(iq_path, 'rb') as src, open(segment_path, 'wb') as dst:
+ with open(iq_path, "rb") as src, open(segment_path, "wb") as dst:
src.seek(start_byte)
remaining = segment_size
while remaining > 0:
@@ -2014,46 +2139,50 @@ class SubGhzManager:
written = segment_path.stat().st_size if segment_path.exists() else 0
except OSError as exc:
logger.error(f"Failed to build TX segment: {exc}")
- return {'status': 'error', 'message': 'Failed to create TX segment'}
+ return {"status": "error", "message": "Failed to create TX segment"}
if written < 2:
try:
segment_path.unlink(missing_ok=True) # type: ignore[arg-type]
except Exception:
pass
- return {'status': 'error', 'message': 'Selected TX segment has no IQ data'}
+ return {"status": "error", "message": "Selected TX segment has no IQ data"}
tx_path = segment_path
segment_info = {
- 'start_seconds': round(start_s, 3),
- 'duration_seconds': round(written / bytes_per_second, 3),
- 'bytes': int(written),
+ "start_seconds": round(start_s, 3),
+ "duration_seconds": round(written / bytes_per_second, 3),
+ "bytes": int(written),
}
with self._lock:
- if self.active_mode != 'idle':
+ if self.active_mode != "idle":
# Clean up segment file if we prepared one
if segment_path_for_cleanup:
try:
segment_path_for_cleanup.unlink(missing_ok=True) # type: ignore[arg-type]
except Exception:
pass
- return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
+ return {"status": "error", "message": f"Already running: {self.active_mode}"}
# Clear any orphaned temp segment from a previous TX attempt.
self._cleanup_tx_temp_file()
- if segment_path_for_cleanup:
- self._tx_temp_file = segment_path_for_cleanup
-
- cmd = [
- hackrf_transfer_path,
- '-t', str(tx_path),
- '-f', str(capture.frequency_hz),
- '-s', str(capture.sample_rate),
- '-x', str(tx_gain),
+ if segment_path_for_cleanup:
+ self._tx_temp_file = segment_path_for_cleanup
+
+ cmd = [
+ hackrf_transfer_path,
+ "-t",
+ str(tx_path),
+ "-f",
+ str(capture.frequency_hz),
+ "-s",
+ str(capture.sample_rate),
+ "-x",
+ str(tx_gain),
]
if device_serial:
- cmd.extend(['-d', device_serial])
+ cmd.extend(["-d", device_serial])
logger.info(f"SubGHz TX: {' '.join(cmd)}")
@@ -2068,9 +2197,7 @@ class SubGhzManager:
self._tx_capture_id = capture_id
# Start watchdog timer
- self._tx_watchdog = threading.Timer(
- max_duration, self._tx_watchdog_kill
- )
+ self._tx_watchdog = threading.Timer(max_duration, self._tx_watchdog_kill)
self._tx_watchdog.daemon = True
self._tx_watchdog.start()
@@ -2080,30 +2207,32 @@ class SubGhzManager:
daemon=True,
).start()
- self._emit({
- 'type': 'tx_status',
- 'status': 'transmitting',
- 'capture_id': capture_id,
- 'frequency_hz': capture.frequency_hz,
- 'max_duration': max_duration,
- 'segment': segment_info,
- })
+ self._emit(
+ {
+ "type": "tx_status",
+ "status": "transmitting",
+ "capture_id": capture_id,
+ "frequency_hz": capture.frequency_hz,
+ "max_duration": max_duration,
+ "segment": segment_info,
+ }
+ )
return {
- 'status': 'transmitting',
- 'capture_id': capture_id,
- 'frequency_hz': capture.frequency_hz,
- 'max_duration': max_duration,
- 'segment': segment_info,
+ "status": "transmitting",
+ "capture_id": capture_id,
+ "frequency_hz": capture.frequency_hz,
+ "max_duration": max_duration,
+ "segment": segment_info,
}
except FileNotFoundError:
self._cleanup_tx_temp_file()
- return {'status': 'error', 'message': 'hackrf_transfer not found'}
+ return {"status": "error", "message": "hackrf_transfer not found"}
except Exception as e:
self._cleanup_tx_temp_file()
logger.error(f"Failed to start TX: {e}")
- return {'status': 'error', 'message': str(e)}
+ return {"status": "error", "message": str(e)}
def _tx_watchdog_kill(self) -> None:
"""Kill TX process when max duration is exceeded."""
@@ -2127,18 +2256,22 @@ class SubGhzManager:
if returncode and returncode != 0 and returncode != -15:
# Non-zero exit (not SIGTERM) means unexpected death
logger.warning(f"hackrf_transfer TX exited unexpectedly (rc={returncode})")
- self._emit({
- 'type': 'error',
- 'message': f'Transmission failed (hackrf_transfer exited with code {returncode})',
- })
+ self._emit(
+ {
+ "type": "error",
+ "message": f"Transmission failed (hackrf_transfer exited with code {returncode})",
+ }
+ )
self._tx_process = None
self._tx_start_time = 0
- self._tx_capture_id = ''
- self._emit({
- 'type': 'tx_status',
- 'status': 'tx_complete',
- 'duration_seconds': round(duration, 1),
- })
+ self._tx_capture_id = ""
+ self._emit(
+ {
+ "type": "tx_status",
+ "status": "tx_complete",
+ "duration_seconds": round(duration, 1),
+ }
+ )
if self._tx_watchdog:
self._tx_watchdog.cancel()
self._tx_watchdog = None
@@ -2153,13 +2286,13 @@ class SubGhzManager:
if not self._tx_process or self._tx_process.poll() is not None:
self._cleanup_tx_temp_file()
- return {'status': 'not_running'}
+ return {"status": "not_running"}
proc_to_terminate = self._tx_process
self._tx_process = None
duration = time.time() - self._tx_start_time if self._tx_start_time else 0
self._tx_start_time = 0
- self._tx_capture_id = ''
+ self._tx_capture_id = ""
self._cleanup_tx_temp_file()
# Terminate outside lock to avoid blocking other operations
@@ -2167,50 +2300,54 @@ class SubGhzManager:
safe_terminate(proc_to_terminate)
unregister_process(proc_to_terminate)
- self._emit({
- 'type': 'tx_status',
- 'status': 'tx_stopped',
- 'duration_seconds': round(duration, 1),
- })
+ self._emit(
+ {
+ "type": "tx_status",
+ "status": "tx_stopped",
+ "duration_seconds": round(duration, 1),
+ }
+ )
- return {'status': 'stopped', 'duration_seconds': round(duration, 1)}
+ return {"status": "stopped", "duration_seconds": round(duration, 1)}
# ------------------------------------------------------------------
# SWEEP (hackrf_sweep)
# ------------------------------------------------------------------
- def start_sweep(
- self,
- freq_start_mhz: float = 300.0,
- freq_end_mhz: float = 928.0,
- bin_width: int = 100000,
- device_serial: str | None = None,
- ) -> dict:
- # Pre-lock: tool availability & device detection (blocking I/O)
- hackrf_sweep_path = self._resolve_tool('hackrf_sweep')
- if not hackrf_sweep_path:
- return {'status': 'error', 'message': 'hackrf_sweep not found'}
- device_err = self._require_hackrf_device()
- if device_err:
- return {'status': 'error', 'message': device_err}
+ def start_sweep(
+ self,
+ freq_start_mhz: float = 300.0,
+ freq_end_mhz: float = 928.0,
+ bin_width: int = 100000,
+ device_serial: str | None = None,
+ ) -> dict:
+ # Pre-lock: tool availability & device detection (blocking I/O)
+ hackrf_sweep_path = self._resolve_tool("hackrf_sweep")
+ if not hackrf_sweep_path:
+ return {"status": "error", "message": "hackrf_sweep not found"}
+ device_err = self._require_hackrf_device()
+ if device_err:
+ return {"status": "error", "message": device_err}
# Wait for previous sweep thread to exit (blocking) before lock
if self._sweep_thread and self._sweep_thread.is_alive():
self._sweep_thread.join(timeout=2.0)
if self._sweep_thread.is_alive():
- return {'status': 'error', 'message': 'Previous sweep still shutting down'}
+ return {"status": "error", "message": "Previous sweep still shutting down"}
with self._lock:
- if self.active_mode != 'idle':
- return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
-
- cmd = [
- hackrf_sweep_path,
- '-f', f'{int(freq_start_mhz)}:{int(freq_end_mhz)}',
- '-w', str(bin_width),
- ]
+ if self.active_mode != "idle":
+ return {"status": "error", "message": f"Already running: {self.active_mode}"}
+
+ cmd = [
+ hackrf_sweep_path,
+ "-f",
+ f"{int(freq_start_mhz)}:{int(freq_end_mhz)}",
+ "-w",
+ str(bin_width),
+ ]
if device_serial:
- cmd.extend(['-d', device_serial])
+ cmd.extend(["-d", device_serial])
logger.info(f"SubGHz sweep: {' '.join(cmd)}")
@@ -2231,25 +2368,27 @@ class SubGhzManager:
)
self._sweep_thread.start()
- self._emit({
- 'type': 'status',
- 'mode': 'sweep',
- 'status': 'started',
- 'freq_start_mhz': freq_start_mhz,
- 'freq_end_mhz': freq_end_mhz,
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "sweep",
+ "status": "started",
+ "freq_start_mhz": freq_start_mhz,
+ "freq_end_mhz": freq_end_mhz,
+ }
+ )
return {
- 'status': 'started',
- 'freq_start_mhz': freq_start_mhz,
- 'freq_end_mhz': freq_end_mhz,
+ "status": "started",
+ "freq_start_mhz": freq_start_mhz,
+ "freq_end_mhz": freq_end_mhz,
}
except FileNotFoundError:
- return {'status': 'error', 'message': 'hackrf_sweep not found'}
+ return {"status": "error", "message": "hackrf_sweep not found"}
except Exception as e:
logger.error(f"Failed to start sweep: {e}")
- return {'status': 'error', 'message': str(e)}
+ return {"status": "error", "message": str(e)}
def _sweep_loop(self, cmd: list[str]) -> None:
"""Run hackrf_sweep with auto-restart on USB drops."""
@@ -2265,7 +2404,7 @@ class SubGhzManager:
break
if restarts >= MAX_RESTARTS:
logger.error("hackrf_sweep: max restarts reached")
- self._emit({'type': 'error', 'message': 'HackRF sweep: max restarts reached'})
+ self._emit({"type": "error", "message": "HackRF sweep: max restarts reached"})
break
time.sleep(RESTART_DELAY)
@@ -2287,11 +2426,13 @@ class SubGhzManager:
break
self._sweep_running = False
- self._emit({
- 'type': 'status',
- 'mode': 'idle',
- 'status': 'sweep_stopped',
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "idle",
+ "status": "sweep_stopped",
+ }
+ )
def _parse_sweep_stdout(self) -> None:
"""Parse hackrf_sweep CSV output into SweepPoint events.
@@ -2303,14 +2444,14 @@ class SubGhzManager:
if not process or not process.stdout:
return
try:
- for line in iter(process.stdout.readline, b''):
+ for line in iter(process.stdout.readline, b""):
if not self._sweep_running:
break
- text = line.decode('utf-8', errors='replace').strip()
+ text = line.decode("utf-8", errors="replace").strip()
if not text:
continue
try:
- parts = text.split(',')
+ parts = text.split(",")
if len(parts) < 7:
continue
hz_low = float(parts[2].strip())
@@ -2323,15 +2464,19 @@ class SubGhzManager:
points = []
for i, power in enumerate(powers):
freq_hz = hz_low + i * hz_bin_width
- points.append({
- 'freq': round(freq_hz / 1_000_000, 4),
- 'power': round(power, 1),
- })
+ points.append(
+ {
+ "freq": round(freq_hz / 1_000_000, 4),
+ "power": round(power, 1),
+ }
+ )
- self._emit({
- 'type': 'sweep',
- 'points': points,
- })
+ self._emit(
+ {
+ "type": "sweep",
+ "points": points,
+ }
+ )
except Exception as exc:
logger.debug(f"Skipping malformed sweep line: {exc}")
continue
@@ -2343,7 +2488,7 @@ class SubGhzManager:
with self._lock:
self._sweep_running = False
if not self._sweep_process or self._sweep_process.poll() is not None:
- return {'status': 'not_running'}
+ return {"status": "not_running"}
proc_to_terminate = self._sweep_process
self._sweep_process = None
@@ -2357,13 +2502,15 @@ class SubGhzManager:
if self._sweep_thread and self._sweep_thread.is_alive():
self._sweep_thread.join(timeout=2.0)
- self._emit({
- 'type': 'status',
- 'mode': 'idle',
- 'status': 'stopped',
- })
+ self._emit(
+ {
+ "type": "status",
+ "mode": "idle",
+ "status": "stopped",
+ }
+ )
- return {'status': 'stopped'}
+ return {"status": "stopped"}
# ------------------------------------------------------------------
# CAPTURE LIBRARY
@@ -2371,53 +2518,55 @@ class SubGhzManager:
def list_captures(self) -> list[SubGhzCapture]:
captures = []
- for meta_path in sorted(self._captures_dir.glob('*.json'), reverse=True):
+ for meta_path in sorted(self._captures_dir.glob("*.json"), reverse=True):
try:
data = json.loads(meta_path.read_text())
- bursts = data.get('bursts', [])
- dominant_fingerprint = data.get('dominant_fingerprint', '')
+ bursts = data.get("bursts", [])
+ dominant_fingerprint = data.get("dominant_fingerprint", "")
if not dominant_fingerprint and isinstance(bursts, list):
fp_counts: dict[str, int] = {}
for burst in bursts:
- fp = ''
+ fp = ""
if isinstance(burst, dict):
- fp = str(burst.get('fingerprint') or '').strip()
+ fp = str(burst.get("fingerprint") or "").strip()
if not fp:
continue
fp_counts[fp] = fp_counts.get(fp, 0) + 1
if fp_counts:
dominant_fingerprint = max(fp_counts, key=fp_counts.get)
- captures.append(SubGhzCapture(
- capture_id=data['id'],
- filename=data['filename'],
- frequency_hz=data['frequency_hz'],
- sample_rate=data['sample_rate'],
- lna_gain=data.get('lna_gain', 0),
- vga_gain=data.get('vga_gain', 0),
- timestamp=data['timestamp'],
- duration_seconds=data.get('duration_seconds', 0),
- size_bytes=data.get('size_bytes', 0),
- label=data.get('label', ''),
- label_source=data.get('label_source', ''),
- decoded_protocols=data.get('decoded_protocols', []),
- bursts=bursts,
- modulation_hint=data.get('modulation_hint', ''),
- modulation_confidence=data.get('modulation_confidence', 0.0),
- protocol_hint=data.get('protocol_hint', ''),
- dominant_fingerprint=dominant_fingerprint,
- fingerprint_group=data.get('fingerprint_group', ''),
- fingerprint_group_size=data.get('fingerprint_group_size', 0),
- trigger_enabled=bool(data.get('trigger_enabled', False)),
- trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0),
- trigger_post_seconds=data.get('trigger_post_seconds', 0.0),
- ))
+ captures.append(
+ SubGhzCapture(
+ capture_id=data["id"],
+ filename=data["filename"],
+ frequency_hz=data["frequency_hz"],
+ sample_rate=data["sample_rate"],
+ lna_gain=data.get("lna_gain", 0),
+ vga_gain=data.get("vga_gain", 0),
+ timestamp=data["timestamp"],
+ duration_seconds=data.get("duration_seconds", 0),
+ size_bytes=data.get("size_bytes", 0),
+ label=data.get("label", ""),
+ label_source=data.get("label_source", ""),
+ decoded_protocols=data.get("decoded_protocols", []),
+ bursts=bursts,
+ modulation_hint=data.get("modulation_hint", ""),
+ modulation_confidence=data.get("modulation_confidence", 0.0),
+ protocol_hint=data.get("protocol_hint", ""),
+ dominant_fingerprint=dominant_fingerprint,
+ fingerprint_group=data.get("fingerprint_group", ""),
+ fingerprint_group_size=data.get("fingerprint_group_size", 0),
+ trigger_enabled=bool(data.get("trigger_enabled", False)),
+ trigger_pre_seconds=data.get("trigger_pre_seconds", 0.0),
+ trigger_post_seconds=data.get("trigger_post_seconds", 0.0),
+ )
+ )
except (json.JSONDecodeError, KeyError, OSError) as e:
logger.debug(f"Skipping invalid capture metadata {meta_path}: {e}")
# Auto-group repeated fingerprints as likely same button/device clusters.
fingerprint_groups: dict[str, list[SubGhzCapture]] = {}
for capture in captures:
- fp = (capture.dominant_fingerprint or '').strip().lower()
+ fp = (capture.dominant_fingerprint or "").strip().lower()
if not fp:
continue
fingerprint_groups.setdefault(fp, []).append(capture)
@@ -2430,46 +2579,46 @@ class SubGhzManager:
return captures
def _load_capture(self, capture_id: str) -> SubGhzCapture | None:
- for meta_path in self._captures_dir.glob('*.json'):
+ for meta_path in self._captures_dir.glob("*.json"):
try:
data = json.loads(meta_path.read_text())
- if data.get('id') == capture_id:
- bursts = data.get('bursts', [])
- dominant_fingerprint = data.get('dominant_fingerprint', '')
+ if data.get("id") == capture_id:
+ bursts = data.get("bursts", [])
+ dominant_fingerprint = data.get("dominant_fingerprint", "")
if not dominant_fingerprint and isinstance(bursts, list):
fp_counts: dict[str, int] = {}
for burst in bursts:
- fp = ''
+ fp = ""
if isinstance(burst, dict):
- fp = str(burst.get('fingerprint') or '').strip()
+ fp = str(burst.get("fingerprint") or "").strip()
if not fp:
continue
fp_counts[fp] = fp_counts.get(fp, 0) + 1
if fp_counts:
dominant_fingerprint = max(fp_counts, key=fp_counts.get)
return SubGhzCapture(
- capture_id=data['id'],
- filename=data['filename'],
- frequency_hz=data['frequency_hz'],
- sample_rate=data['sample_rate'],
- lna_gain=data.get('lna_gain', 0),
- vga_gain=data.get('vga_gain', 0),
- timestamp=data['timestamp'],
- duration_seconds=data.get('duration_seconds', 0),
- size_bytes=data.get('size_bytes', 0),
- label=data.get('label', ''),
- label_source=data.get('label_source', ''),
- decoded_protocols=data.get('decoded_protocols', []),
+ capture_id=data["id"],
+ filename=data["filename"],
+ frequency_hz=data["frequency_hz"],
+ sample_rate=data["sample_rate"],
+ lna_gain=data.get("lna_gain", 0),
+ vga_gain=data.get("vga_gain", 0),
+ timestamp=data["timestamp"],
+ duration_seconds=data.get("duration_seconds", 0),
+ size_bytes=data.get("size_bytes", 0),
+ label=data.get("label", ""),
+ label_source=data.get("label_source", ""),
+ decoded_protocols=data.get("decoded_protocols", []),
bursts=bursts,
- modulation_hint=data.get('modulation_hint', ''),
- modulation_confidence=data.get('modulation_confidence', 0.0),
- protocol_hint=data.get('protocol_hint', ''),
+ modulation_hint=data.get("modulation_hint", ""),
+ modulation_confidence=data.get("modulation_confidence", 0.0),
+ protocol_hint=data.get("protocol_hint", ""),
dominant_fingerprint=dominant_fingerprint,
- fingerprint_group=data.get('fingerprint_group', ''),
- fingerprint_group_size=data.get('fingerprint_group_size', 0),
- trigger_enabled=bool(data.get('trigger_enabled', False)),
- trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0),
- trigger_post_seconds=data.get('trigger_post_seconds', 0.0),
+ fingerprint_group=data.get("fingerprint_group", ""),
+ fingerprint_group_size=data.get("fingerprint_group_size", 0),
+ trigger_enabled=bool(data.get("trigger_enabled", False)),
+ trigger_pre_seconds=data.get("trigger_pre_seconds", 0.0),
+ trigger_post_seconds=data.get("trigger_post_seconds", 0.0),
)
except (json.JSONDecodeError, KeyError, OSError):
continue
@@ -2492,7 +2641,7 @@ class SubGhzManager:
capture_id: str,
start_seconds: float | None = None,
duration_seconds: float | None = None,
- label: str = '',
+ label: str = "",
) -> dict:
"""Create a trimmed capture from a selected IQ time window.
@@ -2500,27 +2649,27 @@ class SubGhzManager:
window is selected automatically with short padding.
"""
with self._lock:
- if self.active_mode != 'idle':
- return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
+ if self.active_mode != "idle":
+ return {"status": "error", "message": f"Already running: {self.active_mode}"}
capture = self._load_capture(capture_id)
if not capture:
- return {'status': 'error', 'message': f'Capture not found: {capture_id}'}
+ return {"status": "error", "message": f"Capture not found: {capture_id}"}
src_path = self._captures_dir / capture.filename
if not src_path.exists():
- return {'status': 'error', 'message': 'IQ file missing'}
+ return {"status": "error", "message": "IQ file missing"}
try:
src_size = src_path.stat().st_size
except OSError:
- return {'status': 'error', 'message': 'Unable to read capture file'}
+ return {"status": "error", "message": "Unable to read capture file"}
if src_size < 2:
- return {'status': 'error', 'message': 'Capture file has no IQ data'}
+ return {"status": "error", "message": "Capture file has no IQ data"}
total_duration = self._estimate_capture_duration_seconds(capture, src_size)
if total_duration <= 0:
- return {'status': 'error', 'message': 'Unable to determine capture duration'}
+ return {"status": "error", "message": "Unable to determine capture duration"}
use_auto_burst = start_seconds is None and duration_seconds is None
auto_pad = 0.06
@@ -2530,59 +2679,63 @@ class SubGhzManager:
for burst in bursts:
if not isinstance(burst, dict):
continue
- dur = float(burst.get('duration_seconds', 0.0) or 0.0)
+ dur = float(burst.get("duration_seconds", 0.0) or 0.0)
if dur <= 0:
continue
if best_burst is None:
best_burst = burst
continue
- best_peak = float(best_burst.get('peak_level', 0.0) or 0.0)
- cur_peak = float(burst.get('peak_level', 0.0) or 0.0)
- if cur_peak > best_peak or cur_peak == best_peak and dur > float(best_burst.get('duration_seconds', 0.0) or 0.0):
+ best_peak = float(best_burst.get("peak_level", 0.0) or 0.0)
+ cur_peak = float(burst.get("peak_level", 0.0) or 0.0)
+ if (
+ cur_peak > best_peak
+ or cur_peak == best_peak
+ and dur > float(best_burst.get("duration_seconds", 0.0) or 0.0)
+ ):
best_burst = burst
if best_burst:
- burst_start = max(0.0, float(best_burst.get('start_seconds', 0.0) or 0.0))
- burst_dur = max(0.0, float(best_burst.get('duration_seconds', 0.0) or 0.0))
+ burst_start = max(0.0, float(best_burst.get("start_seconds", 0.0) or 0.0))
+ burst_dur = max(0.0, float(best_burst.get("duration_seconds", 0.0) or 0.0))
start_seconds = max(0.0, burst_start - auto_pad)
end_seconds = min(total_duration, burst_start + burst_dur + auto_pad)
duration_seconds = max(0.0, end_seconds - start_seconds)
else:
return {
- 'status': 'error',
- 'message': 'No burst markers available. Select a segment manually before trimming.',
+ "status": "error",
+ "message": "No burst markers available. Select a segment manually before trimming.",
}
try:
start_s = max(0.0, float(start_seconds or 0.0))
except (TypeError, ValueError):
- return {'status': 'error', 'message': 'Invalid start_seconds'}
+ return {"status": "error", "message": "Invalid start_seconds"}
try:
seg_s = None if duration_seconds is None else float(duration_seconds)
except (TypeError, ValueError):
- return {'status': 'error', 'message': 'Invalid duration_seconds'}
+ return {"status": "error", "message": "Invalid duration_seconds"}
if seg_s is not None and seg_s <= 0:
- return {'status': 'error', 'message': 'duration_seconds must be greater than 0'}
+ return {"status": "error", "message": "duration_seconds must be greater than 0"}
if start_s >= total_duration:
- return {'status': 'error', 'message': 'start_seconds is beyond end of capture'}
+ return {"status": "error", "message": "start_seconds is beyond end of capture"}
end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s)
if end_s <= start_s:
- return {'status': 'error', 'message': 'Selected segment is empty'}
+ return {"status": "error", "message": "Selected segment is empty"}
bytes_per_second = max(2, int(capture.sample_rate) * 2)
start_byte = int(start_s * bytes_per_second) & ~1
end_byte = int(end_s * bytes_per_second) & ~1
if end_byte <= start_byte:
- return {'status': 'error', 'message': 'Selected segment is too short'}
+ return {"status": "error", "message": "Selected segment is too short"}
trim_size = end_byte - start_byte
source_stem = Path(capture.filename).stem
trim_name = f"{source_stem}_trim_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:4]}.iq"
trim_path = self._captures_dir / trim_name
try:
- with open(src_path, 'rb') as src, open(trim_path, 'wb') as dst:
+ with open(src_path, "rb") as src, open(trim_path, "wb") as dst:
src.seek(start_byte)
remaining = trim_size
while remaining > 0:
@@ -2598,14 +2751,14 @@ class SubGhzManager:
trim_path.unlink(missing_ok=True) # type: ignore[arg-type]
except Exception:
pass
- return {'status': 'error', 'message': 'Failed to write trimmed capture'}
+ return {"status": "error", "message": "Failed to write trimmed capture"}
if written < 2:
try:
trim_path.unlink(missing_ok=True) # type: ignore[arg-type]
except Exception:
pass
- return {'status': 'error', 'message': 'Trimmed capture has no IQ data'}
+ return {"status": "error", "message": "Trimmed capture has no IQ data"}
trimmed_duration = round(written / bytes_per_second, 3)
@@ -2614,8 +2767,8 @@ class SubGhzManager:
for burst in capture.bursts:
if not isinstance(burst, dict):
continue
- burst_start = max(0.0, float(burst.get('start_seconds', 0.0) or 0.0))
- burst_dur = max(0.0, float(burst.get('duration_seconds', 0.0) or 0.0))
+ burst_start = max(0.0, float(burst.get("start_seconds", 0.0) or 0.0))
+ burst_dur = max(0.0, float(burst.get("duration_seconds", 0.0) or 0.0))
burst_end = burst_start + burst_dur
overlap_start = max(start_s, burst_start)
overlap_end = min(end_s, burst_end)
@@ -2623,14 +2776,14 @@ class SubGhzManager:
if overlap_dur <= 0:
continue
adjusted = dict(burst)
- adjusted['start_seconds'] = round(overlap_start - start_s, 3)
- adjusted['duration_seconds'] = round(overlap_dur, 3)
+ adjusted["start_seconds"] = round(overlap_start - start_s, 3)
+ adjusted["duration_seconds"] = round(overlap_dur, 3)
adjusted_bursts.append(adjusted)
- dominant_fingerprint = ''
+ dominant_fingerprint = ""
fp_counts: dict[str, int] = {}
for burst in adjusted_bursts:
- fp = str(burst.get('fingerprint') or '').strip()
+ fp = str(burst.get("fingerprint") or "").strip()
if not fp:
continue
fp_counts[fp] = fp_counts.get(fp, 0) + 1
@@ -2644,9 +2797,9 @@ class SubGhzManager:
if adjusted_bursts:
hint_totals: dict[str, float] = {}
for burst in adjusted_bursts:
- hint = str(burst.get('modulation_hint') or '').strip()
- conf = float(burst.get('modulation_confidence') or 0.0)
- if not hint or hint.lower() == 'unknown':
+ hint = str(burst.get("modulation_hint") or "").strip()
+ conf = float(burst.get("modulation_confidence") or 0.0)
+ if not hint or hint.lower() == "unknown":
continue
hint_totals[hint] = hint_totals.get(hint, 0.0) + max(0.05, conf)
if hint_totals:
@@ -2660,21 +2813,24 @@ class SubGhzManager:
len(adjusted_bursts),
)
- manual_label = str(label or '').strip()
+ manual_label = str(label or "").strip()
if manual_label:
capture_label = manual_label
- label_source = 'manual'
+ label_source = "manual"
elif capture.label:
- capture_label = f'{capture.label} (Trim)'
- label_source = 'auto'
+ capture_label = f"{capture.label} (Trim)"
+ label_source = "auto"
else:
- capture_label = self._auto_capture_label(
- capture.frequency_hz,
- len(adjusted_bursts),
- modulation_hint,
- protocol_hint,
- ) + ' (Trim)'
- label_source = 'auto'
+ capture_label = (
+ self._auto_capture_label(
+ capture.frequency_hz,
+ len(adjusted_bursts),
+ modulation_hint,
+ protocol_hint,
+ )
+ + " (Trim)"
+ )
+ label_source = "auto"
trimmed_capture = SubGhzCapture(
capture_id=uuid.uuid4().hex[:12],
@@ -2699,7 +2855,7 @@ class SubGhzManager:
trigger_post_seconds=0.0,
)
- meta_path = trim_path.with_suffix('.json')
+ meta_path = trim_path.with_suffix(".json")
try:
meta_path.write_text(json.dumps(trimmed_capture.to_dict(), indent=2))
except OSError as exc:
@@ -2708,16 +2864,16 @@ class SubGhzManager:
trim_path.unlink(missing_ok=True) # type: ignore[arg-type]
except Exception:
pass
- return {'status': 'error', 'message': 'Failed to write trimmed capture metadata'}
+ return {"status": "error", "message": "Failed to write trimmed capture metadata"}
return {
- 'status': 'ok',
- 'capture': trimmed_capture.to_dict(),
- 'source_capture_id': capture_id,
- 'segment': {
- 'start_seconds': round(start_s, 3),
- 'duration_seconds': round(trimmed_duration, 3),
- 'auto_selected': bool(use_auto_burst),
+ "status": "ok",
+ "capture": trimmed_capture.to_dict(),
+ "source_capture_id": capture_id,
+ "segment": {
+ "start_seconds": round(start_s, 3),
+ "duration_seconds": round(trimmed_duration, 3),
+ "auto_selected": bool(use_auto_burst),
},
}
@@ -2727,7 +2883,7 @@ class SubGhzManager:
return False
iq_path = self._captures_dir / capture.filename
- meta_path = iq_path.with_suffix('.json')
+ meta_path = iq_path.with_suffix(".json")
deleted = False
for path in (iq_path, meta_path):
@@ -2740,12 +2896,12 @@ class SubGhzManager:
return deleted
def update_capture_label(self, capture_id: str, label: str) -> bool:
- for meta_path in self._captures_dir.glob('*.json'):
+ for meta_path in self._captures_dir.glob("*.json"):
try:
data = json.loads(meta_path.read_text())
- if data.get('id') == capture_id:
- data['label'] = label
- data['label_source'] = 'manual' if label else data.get('label_source', '')
+ if data.get("id") == capture_id:
+ data["label"] = label
+ data["label_source"] = "manual" if label else data.get("label_source", "")
meta_path.write_text(json.dumps(data, indent=2))
return True
except (json.JSONDecodeError, KeyError, OSError):
@@ -2772,11 +2928,11 @@ class SubGhzManager:
self._tx_watchdog = None
for proc_attr in (
- '_rx_process',
- '_decode_hackrf_process',
- '_decode_process',
- '_tx_process',
- '_sweep_process',
+ "_rx_process",
+ "_decode_hackrf_process",
+ "_decode_process",
+ "_tx_process",
+ "_sweep_process",
):
process = getattr(self, proc_attr, None)
if process and process.poll() is None:
@@ -2793,7 +2949,7 @@ class SubGhzManager:
self._cleanup_tx_temp_file()
self._rx_file = None
- self._tx_capture_id = ''
+ self._tx_capture_id = ""
self._rx_start_time = 0
self._rx_bytes_written = 0
@@ -2802,9 +2958,9 @@ class SubGhzManager:
self._rx_trigger_first_burst_start = None
self._rx_trigger_last_burst_end = None
self._rx_autostop_pending = False
- self._rx_modulation_hint = ''
+ self._rx_modulation_hint = ""
self._rx_modulation_confidence = 0.0
- self._rx_protocol_hint = ''
+ self._rx_protocol_hint = ""
self._rx_fingerprint_counts = {}
self._tx_start_time = 0
self._decode_start_time = 0
From e33dff1ab93dadcee24e5f5099468af170df42ca Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 11:13:07 +0100
Subject: [PATCH 02/14] chore: add .worktrees/ to .gitignore
---
.gitignore | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.gitignore b/.gitignore
index 4e40d58..2af7fed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,3 +69,6 @@ data/subghz/captures/
reset-sdr.*
.superpowers/
docs/superpowers/
+
+# Git worktrees
+.worktrees/
From b707468cb6940341cafd5e7f1113d8a9d6134552 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 11:42:11 +0100
Subject: [PATCH 03/14] feat(drone): add data models and RF signature table
---
tests/test_drone_models.py | 67 +++++++++++++++++++++++++++++
utils/drone/__init__.py | 5 +++
utils/drone/models.py | 87 ++++++++++++++++++++++++++++++++++++++
utils/drone/signatures.py | 34 +++++++++++++++
4 files changed, 193 insertions(+)
create mode 100644 tests/test_drone_models.py
create mode 100644 utils/drone/__init__.py
create mode 100644 utils/drone/models.py
create mode 100644 utils/drone/signatures.py
diff --git a/tests/test_drone_models.py b/tests/test_drone_models.py
new file mode 100644
index 0000000..4577c7a
--- /dev/null
+++ b/tests/test_drone_models.py
@@ -0,0 +1,67 @@
+# tests/test_drone_models.py
+from datetime import datetime, timezone
+
+from utils.drone.models import DroneContact, RFSignal
+from utils.drone.signatures import match_signature
+
+
+def _now():
+ return datetime.now(timezone.utc)
+
+
+def test_drone_contact_to_dict_minimal():
+ c = DroneContact(id="abc123", first_seen=_now(), last_seen=_now())
+ d = c.to_dict()
+ assert d["id"] == "abc123"
+ assert d["compliant"] is False
+ assert d["risk_level"] == "low"
+ assert d["detection_vectors"] == []
+ assert d["position"] is None
+
+
+def test_drone_contact_to_dict_with_position():
+ c = DroneContact(id="xyz", first_seen=_now(), last_seen=_now())
+ c.position = (51.5, -0.1)
+ c.serial_number = "SN001"
+ c.compliant = True
+ c.detection_vectors = {"REMOTE_ID_WIFI"}
+ d = c.to_dict()
+ assert d["position"] == [51.5, -0.1]
+ assert d["serial_number"] == "SN001"
+ assert d["detection_vectors"] == ["REMOTE_ID_WIFI"]
+
+
+def test_drone_contact_position_history_capped():
+ c = DroneContact(id="cap", first_seen=_now(), last_seen=_now())
+ for i in range(510):
+ c.position_history.append((float(i), float(i), _now()))
+ d = c.to_dict()
+ # to_dict sends last 50
+ assert len(d["position_history"]) == 50
+
+
+def test_rf_signal_fields():
+ s = RFSignal(frequency_hz=433_920_000, protocol="FRSKY", rssi=-65.0, hardware="RTL433", timestamp=_now())
+ assert s.frequency_hz == 433_920_000
+ assert s.protocol == "FRSKY"
+
+
+def test_match_signature_frsky_433():
+ assert match_signature(433_920_000) == "FRSKY"
+
+
+def test_match_signature_ocusync_24():
+ assert match_signature(2_440_000_000) == "DJI_OCUSYNC"
+
+
+def test_match_signature_fpv_58():
+ assert match_signature(5_800_000_000) == "FPV_VIDEO"
+
+
+def test_match_signature_ocusync_at_2450mhz():
+ # 2,450 MHz is within the DJI_OCUSYNC band
+ assert match_signature(2_450_000_000) == "DJI_OCUSYNC"
+
+
+def test_match_signature_unrecognised():
+ assert match_signature(100_000_000) == "UNKNOWN"
diff --git a/utils/drone/__init__.py b/utils/drone/__init__.py
new file mode 100644
index 0000000..60b3f3a
--- /dev/null
+++ b/utils/drone/__init__.py
@@ -0,0 +1,5 @@
+"""Drone intelligence utilities — multi-vector UAV detection."""
+
+from .models import DroneContact, RemoteIDObservation, RFObservation, RFSignal
+
+__all__ = ["DroneContact", "RemoteIDObservation", "RFObservation", "RFSignal"]
diff --git a/utils/drone/models.py b/utils/drone/models.py
new file mode 100644
index 0000000..2f9614b
--- /dev/null
+++ b/utils/drone/models.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+
+_MAX_HISTORY_IN_DICT = 50
+_MAX_RF_IN_DICT = 10
+
+
+@dataclass
+class RFSignal:
+ frequency_hz: int
+ protocol: str
+ rssi: float
+ hardware: str # "RTL433" | "HACKRF"
+ timestamp: datetime
+
+
+@dataclass
+class RemoteIDObservation:
+ source: str # "WIFI" | "BLE"
+ serial_number: str
+ operator_id: str
+ lat: float
+ lon: float
+ altitude_m: float
+ speed_ms: float
+ heading: float
+ timestamp: datetime
+
+
+@dataclass
+class RFObservation:
+ frequency_hz: int
+ protocol: str
+ rssi: float
+ hardware: str # "RTL433" | "HACKRF"
+ timestamp: datetime
+
+
+@dataclass
+class DroneContact:
+ id: str
+ first_seen: datetime
+ last_seen: datetime
+ serial_number: str | None = None
+ operator_id: str | None = None
+ position: tuple[float, float] | None = None
+ altitude_m: float | None = None
+ speed_ms: float | None = None
+ heading: float | None = None
+ position_history: list[tuple[float, float, datetime]] = field(default_factory=list)
+ rf_signals: list[RFSignal] = field(default_factory=list)
+ compliant: bool = False
+ detection_vectors: set[str] = field(default_factory=set)
+ confidence: float = 0.0
+ risk_level: str = "low"
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "first_seen": self.first_seen.isoformat(),
+ "last_seen": self.last_seen.isoformat(),
+ "serial_number": self.serial_number,
+ "operator_id": self.operator_id,
+ "position": list(self.position) if self.position else None,
+ "altitude_m": self.altitude_m,
+ "speed_ms": self.speed_ms,
+ "heading": self.heading,
+ "position_history": [
+ {"lat": p[0], "lon": p[1], "ts": p[2].isoformat()}
+ for p in self.position_history[-_MAX_HISTORY_IN_DICT:]
+ ],
+ "rf_signals": [
+ {
+ "frequency_hz": s.frequency_hz,
+ "protocol": s.protocol,
+ "rssi": s.rssi,
+ "hardware": s.hardware,
+ }
+ for s in self.rf_signals[-_MAX_RF_IN_DICT:]
+ ],
+ "compliant": self.compliant,
+ "detection_vectors": sorted(self.detection_vectors),
+ "confidence": round(self.confidence, 2),
+ "risk_level": self.risk_level,
+ }
diff --git a/utils/drone/signatures.py b/utils/drone/signatures.py
new file mode 100644
index 0000000..f9f2743
--- /dev/null
+++ b/utils/drone/signatures.py
@@ -0,0 +1,34 @@
+"""Drone RF protocol signature table and frequency matcher."""
+
+from __future__ import annotations
+
+_SIGNATURES = [
+ {
+ "name": "FRSKY",
+ "freq_min_hz": 433_050_000,
+ "freq_max_hz": 434_790_000,
+ },
+ {
+ "name": "FRSKY_868",
+ "freq_min_hz": 868_000_000,
+ "freq_max_hz": 868_600_000,
+ },
+ {
+ "name": "DJI_OCUSYNC",
+ "freq_min_hz": 2_400_000_000,
+ "freq_max_hz": 2_483_500_000,
+ },
+ {
+ "name": "FPV_VIDEO",
+ "freq_min_hz": 5_725_000_000,
+ "freq_max_hz": 5_875_000_000,
+ },
+]
+
+
+def match_signature(frequency_hz: int) -> str:
+ """Return the protocol name for a detected frequency, or 'UNKNOWN'."""
+ for sig in _SIGNATURES:
+ if sig["freq_min_hz"] <= frequency_hz <= sig["freq_max_hz"]:
+ return sig["name"]
+ return "UNKNOWN"
From 772b5d0973a32df58115fba99bd50b832a91e16b Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 12:07:05 +0100
Subject: [PATCH 04/14] feat(drone): add DroneCorrelator with TTL store and
risk scoring
Co-Authored-By: Claude Sonnet 4.6
---
tests/test_drone_correlator.py | 134 +++++++++++++++++++++++++++++++++
utils/drone/correlator.py | 87 +++++++++++++++++++++
2 files changed, 221 insertions(+)
create mode 100644 tests/test_drone_correlator.py
create mode 100644 utils/drone/correlator.py
diff --git a/tests/test_drone_correlator.py b/tests/test_drone_correlator.py
new file mode 100644
index 0000000..23bcc23
--- /dev/null
+++ b/tests/test_drone_correlator.py
@@ -0,0 +1,134 @@
+# tests/test_drone_correlator.py
+import queue
+import time
+from datetime import datetime, timezone
+
+import pytest
+
+from utils.drone.correlator import DroneCorrelator
+from utils.drone.models import RemoteIDObservation, RFObservation
+
+
+def _now():
+ return datetime.now(timezone.utc)
+
+
+def _remote_id_obs(serial="SN001", lat=51.5, lon=-0.1):
+ return RemoteIDObservation(
+ source="WIFI",
+ serial_number=serial,
+ operator_id="OP001",
+ lat=lat,
+ lon=lon,
+ altitude_m=50.0,
+ speed_ms=5.0,
+ heading=90.0,
+ timestamp=_now(),
+ )
+
+
+def _rf_obs(freq=433_920_000, proto="FRSKY", rssi=-70.0):
+ return RFObservation(
+ frequency_hz=freq,
+ protocol=proto,
+ rssi=rssi,
+ hardware="RTL433",
+ timestamp=_now(),
+ )
+
+
+@pytest.fixture
+def correlator():
+ q = queue.Queue()
+ return DroneCorrelator(output_queue=q), q
+
+
+def test_remote_id_creates_contact(correlator):
+ corr, q = correlator
+ corr.process(_remote_id_obs())
+ contacts = corr.get_all()
+ assert len(contacts) == 1
+ assert contacts[0]["compliant"] is True
+ assert contacts[0]["serial_number"] == "SN001"
+ assert contacts[0]["position"] == [51.5, -0.1]
+
+
+def test_rf_creates_contact(correlator):
+ corr, q = correlator
+ corr.process(_rf_obs())
+ contacts = corr.get_all()
+ assert len(contacts) == 1
+ assert contacts[0]["compliant"] is False
+
+
+def test_remote_id_emits_sse_event(correlator):
+ corr, q = correlator
+ corr.process(_remote_id_obs())
+ msg = q.get_nowait()
+ assert msg["type"] == "contact"
+ assert msg["data"]["serial_number"] == "SN001"
+
+
+def test_same_serial_updates_contact(correlator):
+ corr, q = correlator
+ corr.process(_remote_id_obs(lat=51.5, lon=-0.1))
+ corr.process(_remote_id_obs(lat=51.6, lon=-0.2))
+ contacts = corr.get_all()
+ assert len(contacts) == 1
+ assert contacts[0]["position"] == [51.6, -0.2]
+
+
+def test_different_serials_create_separate_contacts(correlator):
+ corr, q = correlator
+ corr.process(_remote_id_obs(serial="SN001"))
+ corr.process(_remote_id_obs(serial="SN002"))
+ contacts = corr.get_all()
+ assert len(contacts) == 2
+
+
+def test_position_history_grows(correlator):
+ corr, q = correlator
+ for i in range(5):
+ corr.process(_remote_id_obs(lat=51.0 + i * 0.01, lon=-0.1))
+ contacts = corr.get_all()
+ assert len(contacts[0]["position_history"]) == 5
+
+
+def test_position_history_capped_at_500(correlator):
+ corr, q = correlator
+ for i in range(510):
+ corr.process(_remote_id_obs(lat=float(i), lon=0.0))
+ store_values = list(corr._store.values())
+ assert len(store_values[0].position_history) == 500
+
+
+def test_compliant_single_vector_is_low_risk(correlator):
+ corr, q = correlator
+ corr.process(_remote_id_obs())
+ contacts = corr.get_all()
+ assert contacts[0]["risk_level"] == "low"
+
+
+def test_non_compliant_is_high_risk(correlator):
+ corr, q = correlator
+ corr.process(_rf_obs())
+ contacts = corr.get_all()
+ assert contacts[0]["risk_level"] == "high"
+
+
+def test_confidence_increases_with_vectors(correlator):
+ corr, q = correlator
+ corr.process(_remote_id_obs())
+ contacts = {c["id"]: c for c in corr.get_all()}
+ rid_contact = next(c for c in contacts.values() if c["compliant"])
+ assert rid_contact["confidence"] == 0.25 # 1/4
+
+
+def test_ttl_expiry_removes_contact(correlator):
+ corr, q = correlator
+ corr.process(_remote_id_obs())
+ assert len(corr.get_all()) == 1
+ for key in corr._store.timestamps:
+ corr._store.timestamps[key] = time.time() - 300
+ corr._store.cleanup()
+ assert len(corr.get_all()) == 0
diff --git a/utils/drone/correlator.py b/utils/drone/correlator.py
new file mode 100644
index 0000000..cdade25
--- /dev/null
+++ b/utils/drone/correlator.py
@@ -0,0 +1,87 @@
+# utils/drone/correlator.py
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import queue
+from datetime import datetime, timezone
+
+from utils.cleanup import DataStore, cleanup_manager
+
+from .models import DroneContact, RemoteIDObservation, RFObservation, RFSignal
+
+_CONTACT_TTL = 120.0
+_MAX_POSITION_HISTORY = 500
+
+
+def _contact_id_from_serial(serial: str) -> str:
+ return hashlib.sha1(f"serial:{serial}".encode()).hexdigest()[:12]
+
+
+def _contact_id_from_rf(freq_hz: int, protocol: str) -> str:
+ return hashlib.sha1(f"rf:{freq_hz}:{protocol}".encode()).hexdigest()[:12]
+
+
+def _compute_risk(contact: DroneContact) -> str:
+ if not contact.compliant:
+ return "high"
+ if len(contact.detection_vectors) > 1:
+ return "medium"
+ if len(contact.rf_signals) >= 2:
+ recent = sorted(contact.rf_signals, key=lambda s: s.timestamp)[-5:]
+ if abs(recent[-1].rssi - recent[0].rssi) > 15:
+ return "medium"
+ return "low"
+
+
+class DroneCorrelator:
+ def __init__(self, output_queue: queue.Queue) -> None:
+ self._store: DataStore = DataStore(max_age_seconds=_CONTACT_TTL, name="drone_contacts")
+ self._output_queue = output_queue
+ cleanup_manager.register(self._store)
+
+ def process(self, obs: RemoteIDObservation | RFObservation) -> None:
+ now = datetime.now(timezone.utc)
+
+ if isinstance(obs, RemoteIDObservation):
+ contact_id = _contact_id_from_serial(obs.serial_number)
+ contact: DroneContact = self._store.get(contact_id) or DroneContact(
+ id=contact_id, first_seen=now, last_seen=now
+ )
+ contact.last_seen = now
+ contact.serial_number = obs.serial_number
+ contact.operator_id = obs.operator_id
+ contact.position = (obs.lat, obs.lon)
+ contact.altitude_m = obs.altitude_m
+ contact.speed_ms = obs.speed_ms
+ contact.heading = obs.heading
+ contact.compliant = True
+ contact.detection_vectors.add(f"REMOTE_ID_{obs.source}")
+ contact.position_history.append((obs.lat, obs.lon, now))
+ if len(contact.position_history) > _MAX_POSITION_HISTORY:
+ contact.position_history = contact.position_history[-_MAX_POSITION_HISTORY:]
+ else:
+ contact_id = _contact_id_from_rf(obs.frequency_hz, obs.protocol)
+ contact = self._store.get(contact_id) or DroneContact(id=contact_id, first_seen=now, last_seen=now)
+ contact.last_seen = now
+ contact.compliant = False
+ contact.detection_vectors.add(obs.hardware)
+ contact.rf_signals.append(
+ RFSignal(
+ frequency_hz=obs.frequency_hz,
+ protocol=obs.protocol,
+ rssi=obs.rssi,
+ hardware=obs.hardware,
+ timestamp=now,
+ )
+ )
+
+ contact.confidence = min(len(contact.detection_vectors) / 4.0, 1.0)
+ contact.risk_level = _compute_risk(contact)
+ self._store.set(contact_id, contact)
+
+ with contextlib.suppress(queue.Full):
+ self._output_queue.put_nowait({"type": "contact", "data": contact.to_dict()})
+
+ def get_all(self) -> list[dict]:
+ return [c.to_dict() for c in self._store.values()]
From a6ce5d5426947216e64f6979c649364967595321 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 12:39:06 +0100
Subject: [PATCH 05/14] feat(drone): add RemoteIDScanner with BLE/WiFi ASTM
F3411 parsing
Co-Authored-By: Claude Sonnet 4.6
---
tests/test_drone_remote_id.py | 92 +++++++++++++++++++++++++
utils/drone/remote_id.py | 122 ++++++++++++++++++++++++++++++++++
2 files changed, 214 insertions(+)
create mode 100644 tests/test_drone_remote_id.py
create mode 100644 utils/drone/remote_id.py
diff --git a/tests/test_drone_remote_id.py b/tests/test_drone_remote_id.py
new file mode 100644
index 0000000..05bc73b
--- /dev/null
+++ b/tests/test_drone_remote_id.py
@@ -0,0 +1,92 @@
+# tests/test_drone_remote_id.py
+import queue
+import struct
+from unittest.mock import MagicMock, patch
+
+from utils.drone.remote_id import RemoteIDScanner, _parse_ble_remote_id, _parse_wifi_remote_id
+
+
+def _make_location_payload(lat=51.5, lon=-0.1, alt=50.0, speed=5.0, heading=90.0) -> bytes:
+ """Craft a minimal ASTM F3411 Location message (message type 0x01)."""
+ msg_type = 0x01
+ status = 0x00
+ lat_enc = int(lat * 1e7)
+ lon_enc = int(lon * 1e7)
+ alt_enc = int((alt + 1000) / 0.5)
+ speed_enc = int(speed / 0.25)
+ heading_enc = int(heading / 0.01)
+ return struct.pack(" bytes:
+ msg_type = 0x00
+ id_type = 0x01
+ serial_bytes = serial.encode("ascii").ljust(20, b"\x00")[:20]
+ return bytes([msg_type, id_type]) + serial_bytes
+
+
+def _make_ble_adv_with_remote_id(payload: bytes) -> bytes:
+ uuid_bytes = b"\xfa\xff"
+ service_data_type = 0x16
+ length = len(uuid_bytes) + len(payload) + 1
+ return bytes([length, service_data_type]) + uuid_bytes + payload
+
+
+def test_parse_ble_location_returns_observation():
+ payload = _make_location_payload(lat=51.5, lon=-0.1, alt=50.0, speed=5.0, heading=90.0)
+ adv = _make_ble_adv_with_remote_id(payload)
+ obs = _parse_ble_remote_id(adv)
+ assert obs is not None
+ assert obs.source == "BLE"
+ assert abs(obs.lat - 51.5) < 0.0001
+ assert abs(obs.lon - (-0.1)) < 0.0001
+ assert abs(obs.altitude_m - 50.0) < 1.0
+ assert abs(obs.speed_ms - 5.0) < 0.5
+
+
+def test_parse_ble_no_uuid_returns_none():
+ obs = _parse_ble_remote_id(b"\x00\x01\x02\x03")
+ assert obs is None
+
+
+def test_parse_ble_too_short_returns_none():
+ adv = _make_ble_adv_with_remote_id(b"\x01\x00")
+ obs = _parse_ble_remote_id(adv)
+ assert obs is None
+
+
+def test_parse_wifi_remote_id_returns_observation():
+ payload = _make_location_payload(lat=52.0, lon=0.5)
+ obs = _parse_wifi_remote_id(payload)
+ assert obs is not None
+ assert obs.source == "WIFI"
+ assert abs(obs.lat - 52.0) < 0.0001
+
+
+def test_parse_wifi_non_location_returns_none():
+ payload = _make_basic_id_payload()
+ obs = _parse_wifi_remote_id(payload)
+ assert obs is None
+
+
+def test_scanner_start_stop():
+ q = queue.Queue()
+ scanner = RemoteIDScanner(output_queue=q)
+ with (
+ patch("utils.drone.remote_id.SCAPY_AVAILABLE", True),
+ patch("utils.drone.remote_id.AsyncSniffer") as mock_sniffer,
+ ):
+ mock_sniffer.return_value = MagicMock()
+ scanner.start(wifi_iface="wlan0mon")
+ assert scanner.running
+ scanner.stop()
+ assert not scanner.running
+
+
+def test_scanner_start_without_scapy_still_works():
+ q = queue.Queue()
+ scanner = RemoteIDScanner(output_queue=q)
+ with patch("utils.drone.remote_id.SCAPY_AVAILABLE", False):
+ scanner.start(wifi_iface=None)
+ assert scanner.running
+ scanner.stop()
diff --git a/utils/drone/remote_id.py b/utils/drone/remote_id.py
new file mode 100644
index 0000000..8238866
--- /dev/null
+++ b/utils/drone/remote_id.py
@@ -0,0 +1,122 @@
+# utils/drone/remote_id.py
+"""Remote ID scanner — WiFi beacon + BLE advertisement parsing (ASTM F3411)."""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import queue
+import struct
+from datetime import datetime, timezone
+
+from .models import RemoteIDObservation
+
+logger = logging.getLogger("intercept.drone.remote_id")
+
+_REMOTE_ID_UUID_LE = b"\xfa\xff"
+_LOCATION_MSG_TYPE = 0x01
+_MIN_LOCATION_PAYLOAD = 15
+
+try:
+ from scapy.all import AsyncSniffer, Dot11Beacon, Dot11Elt
+
+ SCAPY_AVAILABLE = True
+except ImportError:
+ SCAPY_AVAILABLE = False
+ AsyncSniffer = None
+ Dot11Beacon = Dot11Elt = None
+
+
+def _parse_ble_remote_id(adv_data: bytes) -> RemoteIDObservation | None:
+ """Parse a BLE advertisement containing an ASTM F3411 Remote ID payload."""
+ idx = adv_data.find(_REMOTE_ID_UUID_LE)
+ if idx < 0:
+ return None
+ payload = adv_data[idx + 2 :]
+ return _parse_wifi_remote_id(payload, source="BLE")
+
+
+def _parse_wifi_remote_id(payload: bytes, source: str = "WIFI") -> RemoteIDObservation | None:
+ """Parse raw ASTM F3411 Location payload bytes into a RemoteIDObservation."""
+ if not payload or len(payload) < 2:
+ return None
+ msg_type = payload[0] & 0x0F
+ if msg_type != _LOCATION_MSG_TYPE:
+ return None
+ if len(payload) < _MIN_LOCATION_PAYLOAD:
+ return None
+ try:
+ lat_enc, lon_enc = struct.unpack_from(" None:
+ self._queue = output_queue
+ self._sniffer = None
+ self._running = False
+
+ @property
+ def running(self) -> bool:
+ return self._running
+
+ def _on_wifi_packet(self, pkt) -> None:
+ if not (Dot11Beacon and pkt.haslayer(Dot11Beacon)):
+ return
+ elt = pkt.getlayer(Dot11Elt)
+ while elt:
+ if elt.ID == 221 and elt.info:
+ obs = _parse_wifi_remote_id(elt.info)
+ if obs:
+ with contextlib.suppress(queue.Full):
+ self._queue.put_nowait(obs)
+ elt = elt.payload if hasattr(elt, "payload") and isinstance(elt.payload, Dot11Elt) else None
+
+ def start(self, wifi_iface: str | None = None) -> None:
+ self._running = True
+ if SCAPY_AVAILABLE and wifi_iface:
+ try:
+ self._sniffer = AsyncSniffer(
+ iface=wifi_iface,
+ filter="type mgt subtype beacon",
+ prn=self._on_wifi_packet,
+ store=False,
+ )
+ self._sniffer.start()
+ logger.info("WiFi Remote ID sniffer started on %s", wifi_iface)
+ except Exception as exc:
+ logger.warning("WiFi Remote ID sniffer failed to start: %s", exc)
+ else:
+ logger.info("WiFi Remote ID unavailable (scapy=%s, iface=%s)", SCAPY_AVAILABLE, wifi_iface)
+
+ def stop(self) -> None:
+ self._running = False
+ if self._sniffer:
+ with contextlib.suppress(Exception):
+ self._sniffer.stop()
+ self._sniffer = None
From 5dda961dbb43ac1d05a8d37889875a1630260e91 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 12:42:42 +0100
Subject: [PATCH 06/14] fix(drone): assign self._sniffer only after successful
AsyncSniffer.start()
Prevents a non-running sniffer object being stored when start() raises
(e.g. permission denied or interface not found).
Co-Authored-By: Claude Sonnet 4.6
---
utils/drone/remote_id.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/utils/drone/remote_id.py b/utils/drone/remote_id.py
index 8238866..a4143db 100644
--- a/utils/drone/remote_id.py
+++ b/utils/drone/remote_id.py
@@ -101,13 +101,14 @@ class RemoteIDScanner:
self._running = True
if SCAPY_AVAILABLE and wifi_iface:
try:
- self._sniffer = AsyncSniffer(
+ sniffer = AsyncSniffer(
iface=wifi_iface,
filter="type mgt subtype beacon",
prn=self._on_wifi_packet,
store=False,
)
- self._sniffer.start()
+ sniffer.start()
+ self._sniffer = sniffer
logger.info("WiFi Remote ID sniffer started on %s", wifi_iface)
except Exception as exc:
logger.warning("WiFi Remote ID sniffer failed to start: %s", exc)
From e8b94b6efcbb44e6493116e52836eb72b7467f7a Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 12:52:03 +0100
Subject: [PATCH 07/14] feat(drone): add RFDetector for rtl_433 and
hackrf_sweep control-link detection
Implements RFDetector class that wraps rtl_433 (433/868MHz) and hackrf_sweep
(2.4/5.8GHz) subprocesses, emitting RFObservation objects onto a shared queue.
Includes signature matching, frequency band validation, and power thresholding.
- _handle_rtl433_line(): Parse JSON output, filter drone bands, emit observations
- _handle_hackrf_line(): Parse CSV output, average power levels, threshold at -90dBm
- start()/stop(): Manage subprocess threads for concurrent RF detection
- Graceful handling of missing tools (rtl_433, hackrf_sweep)
Co-Authored-By: Claude Sonnet 4.6
---
tests/test_drone_rf_detector.py | 83 ++++++++++++++++++
utils/drone/rf_detector.py | 146 ++++++++++++++++++++++++++++++++
2 files changed, 229 insertions(+)
create mode 100644 tests/test_drone_rf_detector.py
create mode 100644 utils/drone/rf_detector.py
diff --git a/tests/test_drone_rf_detector.py b/tests/test_drone_rf_detector.py
new file mode 100644
index 0000000..3cb6887
--- /dev/null
+++ b/tests/test_drone_rf_detector.py
@@ -0,0 +1,83 @@
+"""Tests for RFDetector (rtl_433 + hackrf_sweep control-link detection)."""
+
+from __future__ import annotations
+
+import json
+import queue
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from utils.drone.models import RFObservation
+from utils.drone.rf_detector import RFDetector
+
+
+@pytest.fixture
+def detector():
+ q = queue.Queue()
+ return RFDetector(output_queue=q), q
+
+
+def test_detector_not_running_initially(detector):
+ det, q = detector
+ assert not det.running
+
+
+def test_rtl433_json_line_emits_observation(detector):
+ det, q = detector
+ rtl433_line = json.dumps(
+ {
+ "freq": 433920000,
+ "rssi": -68.5,
+ "protocol": "FrSky",
+ }
+ )
+ det._handle_rtl433_line(rtl433_line)
+ obs = q.get_nowait()
+ assert isinstance(obs, RFObservation)
+ assert obs.frequency_hz == 433_920_000
+ assert obs.hardware == "RTL433"
+ assert obs.rssi == -68.5
+
+
+def test_rtl433_non_json_line_ignored(detector):
+ det, q = detector
+ det._handle_rtl433_line("not json at all")
+ assert q.empty()
+
+
+def test_hackrf_sweep_line_emits_observation(detector):
+ det, q = detector
+ # hackrf_sweep CSV: date, time, hz_low, hz_high, hz_bin_width, num_samples, db, db, ...
+ hz_low = 2_440_000_000
+ hz_high = 2_441_000_000
+ sweep_line = f"2026-05-03, 12:00:00, {hz_low}, {hz_high}, 1000000, 10, -45.2, -46.1, -44.8"
+ det._handle_hackrf_line(sweep_line)
+ obs = q.get_nowait()
+ assert isinstance(obs, RFObservation)
+ assert obs.hardware == "HACKRF"
+ assert obs.frequency_hz == hz_low
+ assert obs.rssi < 0
+
+
+def test_hackrf_sweep_below_threshold_ignored(detector):
+ det, q = detector
+ hz_low = 2_440_000_000
+ hz_high = 2_441_000_000
+ # Very low power — should be ignored (below -90 dBm threshold)
+ sweep_line = f"2026-05-03, 12:00:00, {hz_low}, {hz_high}, 1000000, 10, -95.0, -96.0, -95.5"
+ det._handle_hackrf_line(sweep_line)
+ assert q.empty()
+
+
+def test_start_stop(detector):
+ det, q = detector
+ mock_proc = MagicMock()
+ mock_proc.stdout = MagicMock()
+ mock_proc.stdout.readline = MagicMock(side_effect=[b""])
+ with patch("subprocess.Popen", return_value=mock_proc):
+ with patch("shutil.which", return_value="/usr/bin/rtl_433"):
+ det.start(rtl_sdr_index=0)
+ assert det.running
+ det.stop()
+ assert not det.running
diff --git a/utils/drone/rf_detector.py b/utils/drone/rf_detector.py
new file mode 100644
index 0000000..adbd818
--- /dev/null
+++ b/utils/drone/rf_detector.py
@@ -0,0 +1,146 @@
+"""RF control-link detector — rtl_433 (433/868MHz) + hackrf_sweep (2.4/5.8GHz)."""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import logging
+import queue
+import shutil
+import subprocess
+import threading
+from datetime import datetime, timezone
+
+from .models import RFObservation
+from .signatures import match_signature
+
+logger = logging.getLogger("intercept.drone.rf_detector")
+
+_HACKRF_THRESHOLD_DBM = -90.0
+_DRONE_FREQ_RANGES_HZ = [
+ (433_000_000, 435_000_000),
+ (868_000_000, 869_000_000),
+ (2_400_000_000, 2_484_000_000),
+ (5_725_000_000, 5_875_000_000),
+]
+
+
+def _in_drone_band(freq_hz: int) -> bool:
+ return any(lo <= freq_hz <= hi for lo, hi in _DRONE_FREQ_RANGES_HZ)
+
+
+class RFDetector:
+ def __init__(self, output_queue: queue.Queue) -> None:
+ self._queue = output_queue
+ self._running = False
+ self._rtl_proc: subprocess.Popen | None = None
+ self._hackrf_proc: subprocess.Popen | None = None
+ self._threads: list[threading.Thread] = []
+
+ @property
+ def running(self) -> bool:
+ return self._running
+
+ def _handle_rtl433_line(self, line: str) -> None:
+ try:
+ data = json.loads(line)
+ except (json.JSONDecodeError, ValueError):
+ return
+ freq = data.get("freq")
+ rssi = data.get("rssi")
+ if freq is None or rssi is None:
+ return
+ freq_hz = int(float(freq))
+ if not _in_drone_band(freq_hz):
+ return
+ protocol = match_signature(freq_hz)
+ with contextlib.suppress(queue.Full):
+ self._queue.put_nowait(
+ RFObservation(
+ frequency_hz=freq_hz,
+ protocol=protocol,
+ rssi=float(rssi),
+ hardware="RTL433",
+ timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ def _handle_hackrf_line(self, line: str) -> None:
+ parts = [p.strip() for p in line.split(",")]
+ if len(parts) < 7:
+ return
+ try:
+ hz_low = int(parts[2])
+ db_values = [float(p) for p in parts[6:] if p]
+ except (ValueError, IndexError):
+ return
+ if not db_values:
+ return
+ avg_db = sum(db_values) / len(db_values)
+ if avg_db < _HACKRF_THRESHOLD_DBM:
+ return
+ if not _in_drone_band(hz_low):
+ return
+ protocol = match_signature(hz_low)
+ with contextlib.suppress(queue.Full):
+ self._queue.put_nowait(
+ RFObservation(
+ frequency_hz=hz_low,
+ protocol=protocol,
+ rssi=avg_db,
+ hardware="HACKRF",
+ timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ def _run_rtl433(self, device_index: int) -> None:
+ rtl_bin = shutil.which("rtl_433")
+ if not rtl_bin:
+ logger.warning("rtl_433 not found — RTL-SDR RF detection disabled")
+ return
+ cmd = [rtl_bin, "-d", str(device_index), "-F", "json", "-f", "433920000", "-f", "868300000"]
+ try:
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ self._rtl_proc = proc
+ for raw_line in iter(proc.stdout.readline, b""):
+ if not self._running:
+ break
+ self._handle_rtl433_line(raw_line.decode("utf-8", errors="replace").strip())
+ except Exception as exc:
+ logger.warning("rtl_433 error: %s", exc)
+
+ def _run_hackrf(self) -> None:
+ hackrf_bin = shutil.which("hackrf_sweep")
+ if not hackrf_bin:
+ logger.warning("hackrf_sweep not found — HackRF RF detection disabled")
+ return
+ cmd = [hackrf_bin, "-f", "2400:2484", "-f", "5725:5875", "-w", "1000000"]
+ try:
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ self._hackrf_proc = proc
+ for raw_line in iter(proc.stdout.readline, b""):
+ if not self._running:
+ break
+ self._handle_hackrf_line(raw_line.decode("utf-8", errors="replace").strip())
+ except Exception as exc:
+ logger.warning("hackrf_sweep error: %s", exc)
+
+ def start(self, rtl_sdr_index: int = 0, use_hackrf: bool = True) -> None:
+ self._running = True
+ t1 = threading.Thread(target=self._run_rtl433, args=(rtl_sdr_index,), daemon=True)
+ t1.start()
+ self._threads.append(t1)
+ if use_hackrf:
+ t2 = threading.Thread(target=self._run_hackrf, daemon=True)
+ t2.start()
+ self._threads.append(t2)
+
+ def stop(self) -> None:
+ self._running = False
+ for proc in (self._rtl_proc, self._hackrf_proc):
+ if proc:
+ with contextlib.suppress(Exception):
+ proc.terminate()
+ self._rtl_proc = None
+ self._hackrf_proc = None
+ self._threads.clear()
From 681a498461d3e9476bf9366cfaf6de7dc42f2756 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 12:53:41 +0100
Subject: [PATCH 08/14] test(drone): fix test_start_stop isolation and add
out-of-band filter coverage
Co-Authored-By: Claude Sonnet 4.6
---
tests/test_drone_rf_detector.py | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/tests/test_drone_rf_detector.py b/tests/test_drone_rf_detector.py
index 3cb6887..3742093 100644
--- a/tests/test_drone_rf_detector.py
+++ b/tests/test_drone_rf_detector.py
@@ -70,14 +70,25 @@ def test_hackrf_sweep_below_threshold_ignored(detector):
assert q.empty()
+def test_out_of_band_frequency_ignored(detector):
+ det, q = detector
+ # 915 MHz is not in any drone band
+ line = json.dumps({"freq": 915_000_000, "rssi": -50.0, "protocol": "Generic"})
+ det._handle_rtl433_line(line)
+ assert q.empty()
+
+
def test_start_stop(detector):
det, q = detector
mock_proc = MagicMock()
mock_proc.stdout = MagicMock()
mock_proc.stdout.readline = MagicMock(side_effect=[b""])
- with patch("subprocess.Popen", return_value=mock_proc):
- with patch("shutil.which", return_value="/usr/bin/rtl_433"):
- det.start(rtl_sdr_index=0)
+ # Patch both shutil.which calls (rtl_433 in _run_rtl433, hackrf_sweep in _run_hackrf)
+ with (
+ patch("subprocess.Popen", return_value=mock_proc),
+ patch("utils.drone.rf_detector.shutil.which", return_value=None),
+ ):
+ det.start(rtl_sdr_index=0, use_hackrf=False)
assert det.running
det.stop()
assert not det.running
From 59713ffc2282a897412a5ad23a6f47141c80ac4c Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 17:33:55 +0100
Subject: [PATCH 09/14] fix(drone): harden RFDetector threading, subprocess
lifecycle, and frequency accuracy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace _running bool with threading.Event for correct cross-thread visibility
- Add _proc_lock to guard _rtl_proc/_hackrf_proc across worker/main threads
- Use register_process + safe_terminate (pipe close + SIGKILL fallback on timeout)
- Compute HackRF frequency as band midpoint (hz_low+hz_high)//2, not hz_low
- Guard start() for idempotency — double-call no longer leaks threads
Co-Authored-By: Claude Sonnet 4.6
---
tests/test_drone_rf_detector.py | 2 +-
utils/drone/rf_detector.py | 49 +++++++++++++++++++++------------
2 files changed, 33 insertions(+), 18 deletions(-)
diff --git a/tests/test_drone_rf_detector.py b/tests/test_drone_rf_detector.py
index 3742093..6ee654c 100644
--- a/tests/test_drone_rf_detector.py
+++ b/tests/test_drone_rf_detector.py
@@ -56,7 +56,7 @@ def test_hackrf_sweep_line_emits_observation(detector):
obs = q.get_nowait()
assert isinstance(obs, RFObservation)
assert obs.hardware == "HACKRF"
- assert obs.frequency_hz == hz_low
+ assert obs.frequency_hz == (hz_low + hz_high) // 2
assert obs.rssi < 0
diff --git a/utils/drone/rf_detector.py b/utils/drone/rf_detector.py
index adbd818..eea689f 100644
--- a/utils/drone/rf_detector.py
+++ b/utils/drone/rf_detector.py
@@ -11,6 +11,8 @@ import subprocess
import threading
from datetime import datetime, timezone
+from utils.process import register_process, safe_terminate
+
from .models import RFObservation
from .signatures import match_signature
@@ -32,14 +34,16 @@ def _in_drone_band(freq_hz: int) -> bool:
class RFDetector:
def __init__(self, output_queue: queue.Queue) -> None:
self._queue = output_queue
- self._running = False
+ self._stop_event = threading.Event()
+ self._stop_event.set() # starts in stopped state
+ self._proc_lock = threading.Lock()
self._rtl_proc: subprocess.Popen | None = None
self._hackrf_proc: subprocess.Popen | None = None
self._threads: list[threading.Thread] = []
@property
def running(self) -> bool:
- return self._running
+ return not self._stop_event.is_set()
def _handle_rtl433_line(self, line: str) -> None:
try:
@@ -71,6 +75,7 @@ class RFDetector:
return
try:
hz_low = int(parts[2])
+ hz_high = int(parts[3])
db_values = [float(p) for p in parts[6:] if p]
except (ValueError, IndexError):
return
@@ -79,13 +84,14 @@ class RFDetector:
avg_db = sum(db_values) / len(db_values)
if avg_db < _HACKRF_THRESHOLD_DBM:
return
- if not _in_drone_band(hz_low):
+ freq_hz = (hz_low + hz_high) // 2
+ if not _in_drone_band(freq_hz):
return
- protocol = match_signature(hz_low)
+ protocol = match_signature(freq_hz)
with contextlib.suppress(queue.Full):
self._queue.put_nowait(
RFObservation(
- frequency_hz=hz_low,
+ frequency_hz=freq_hz,
protocol=protocol,
rssi=avg_db,
hardware="HACKRF",
@@ -101,11 +107,14 @@ class RFDetector:
cmd = [rtl_bin, "-d", str(device_index), "-F", "json", "-f", "433920000", "-f", "868300000"]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
- self._rtl_proc = proc
+ register_process(proc)
+ with self._proc_lock:
+ self._rtl_proc = proc
for raw_line in iter(proc.stdout.readline, b""):
- if not self._running:
+ if self._stop_event.is_set():
break
self._handle_rtl433_line(raw_line.decode("utf-8", errors="replace").strip())
+ safe_terminate(proc)
except Exception as exc:
logger.warning("rtl_433 error: %s", exc)
@@ -117,16 +126,21 @@ class RFDetector:
cmd = [hackrf_bin, "-f", "2400:2484", "-f", "5725:5875", "-w", "1000000"]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
- self._hackrf_proc = proc
+ register_process(proc)
+ with self._proc_lock:
+ self._hackrf_proc = proc
for raw_line in iter(proc.stdout.readline, b""):
- if not self._running:
+ if self._stop_event.is_set():
break
self._handle_hackrf_line(raw_line.decode("utf-8", errors="replace").strip())
+ safe_terminate(proc)
except Exception as exc:
logger.warning("hackrf_sweep error: %s", exc)
def start(self, rtl_sdr_index: int = 0, use_hackrf: bool = True) -> None:
- self._running = True
+ if self.running:
+ return
+ self._stop_event.clear()
t1 = threading.Thread(target=self._run_rtl433, args=(rtl_sdr_index,), daemon=True)
t1.start()
self._threads.append(t1)
@@ -136,11 +150,12 @@ class RFDetector:
self._threads.append(t2)
def stop(self) -> None:
- self._running = False
- for proc in (self._rtl_proc, self._hackrf_proc):
- if proc:
- with contextlib.suppress(Exception):
- proc.terminate()
- self._rtl_proc = None
- self._hackrf_proc = None
+ self._stop_event.set()
+ with self._proc_lock:
+ rtl_proc = self._rtl_proc
+ hackrf_proc = self._hackrf_proc
+ self._rtl_proc = None
+ self._hackrf_proc = None
+ safe_terminate(rtl_proc)
+ safe_terminate(hackrf_proc)
self._threads.clear()
From f9e8fa896d157b087111c2b7a8d093b2f954ff80 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 17:37:02 +0100
Subject: [PATCH 10/14] feat(drone): add Flask blueprint, register routes, wire
drone_queue
Implements Task 5: creates routes/drone.py with /status, /contacts,
/start, /stop, and /stream (SSE fanout) endpoints; registers the
drone_bp blueprint in routes/__init__.py; adds drone_queue to app.py;
adds opendroneid>=1.0 to requirements.txt. All 39 drone tests pass.
Co-Authored-By: Claude Sonnet 4.6
---
app.py | 3 ++
requirements.txt | 1 +
routes/__init__.py | 5 +-
routes/drone.py | 99 ++++++++++++++++++++++++++++++++++++++
tests/test_drone_routes.py | 63 ++++++++++++++++++++++++
5 files changed, 170 insertions(+), 1 deletion(-)
create mode 100644 routes/drone.py
create mode 100644 tests/test_drone_routes.py
diff --git a/app.py b/app.py
index acf5a8c..b046556 100644
--- a/app.py
+++ b/app.py
@@ -317,6 +317,9 @@ deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
deauth_detector_lock = threading.Lock()
+# Drone Intelligence
+drone_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
+
# ============================================
# GLOBAL STATE DICTIONARIES
# ============================================
diff --git a/requirements.txt b/requirements.txt
index 2567927..41232e7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -30,6 +30,7 @@ meshtastic>=2.0.0
# Deauthentication attack detection (optional - for WiFi TSCM)
scapy>=2.4.5
+opendroneid>=1.0
# QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4
diff --git a/routes/__init__.py b/routes/__init__.py
index ddb23d4..51a3eb0 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -18,6 +18,7 @@ def register_blueprints(app):
from .bt_locate import bt_locate_bp
from .controller import controller_bp
from .correlation import correlation_bp
+ from .drone import drone_bp
from .dsc import dsc_bp
from .gps import gps_bp
from .ground_station import ground_station_bp
@@ -91,6 +92,7 @@ def register_blueprints(app):
app.register_blueprint(system_bp) # System health monitoring
app.register_blueprint(ook_bp) # Generic OOK signal decoder
app.register_blueprint(ground_station_bp) # Ground station automation
+ app.register_blueprint(drone_bp) # Drone intelligence / UAV detection
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
if _csrf:
@@ -99,5 +101,6 @@ def register_blueprints(app):
# Initialize TSCM state with queue and lock from app
import app as app_module
- if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
+
+ if hasattr(app_module, "tscm_queue") and hasattr(app_module, "tscm_lock"):
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
diff --git a/routes/drone.py b/routes/drone.py
new file mode 100644
index 0000000..0d3ea67
--- /dev/null
+++ b/routes/drone.py
@@ -0,0 +1,99 @@
+"""Drone intelligence routes — multi-vector UAV detection."""
+
+from __future__ import annotations
+
+import logging
+
+from flask import Blueprint, Response, jsonify, request
+
+import app as app_module
+from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
+from utils.drone.correlator import DroneCorrelator
+from utils.drone.remote_id import RemoteIDScanner
+from utils.drone.rf_detector import RFDetector
+from utils.sse import sse_stream_fanout
+
+logger = logging.getLogger("intercept.drone")
+
+drone_bp = Blueprint("drone", __name__, url_prefix="/drone")
+
+_correlator: DroneCorrelator | None = None
+_remote_id_scanner: RemoteIDScanner | None = None
+_rf_detector: RFDetector | None = None
+_drone_running = False
+
+
+def _ensure_workers() -> None:
+ global _correlator, _remote_id_scanner, _rf_detector
+ if _correlator is None:
+ _correlator = DroneCorrelator(output_queue=app_module.drone_queue)
+ if _remote_id_scanner is None:
+ _remote_id_scanner = RemoteIDScanner(output_queue=app_module.drone_queue)
+ if _rf_detector is None:
+ _rf_detector = RFDetector(output_queue=app_module.drone_queue)
+
+
+@drone_bp.route("/status")
+def status():
+ vectors = []
+ if _remote_id_scanner and _remote_id_scanner.running:
+ vectors.append("REMOTE_ID")
+ if _rf_detector and _rf_detector.running:
+ vectors.append("RF")
+ return jsonify(
+ {
+ "running": _drone_running,
+ "vectors": vectors,
+ "contact_count": len(_correlator.get_all()) if _correlator else 0,
+ }
+ )
+
+
+@drone_bp.route("/contacts")
+def contacts():
+ if not _correlator:
+ return jsonify([])
+ return jsonify(_correlator.get_all())
+
+
+@drone_bp.route("/start", methods=["POST"])
+def start():
+ global _drone_running
+ _ensure_workers()
+ wifi_iface = request.json.get("wifi_iface") if request.json else None
+ rtl_index = int((request.json or {}).get("rtl_sdr_index", 0))
+ use_hackrf = bool((request.json or {}).get("use_hackrf", True))
+
+ if not _drone_running:
+ _remote_id_scanner.start(wifi_iface=wifi_iface)
+ _rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf)
+ _drone_running = True
+ logger.info("Drone detection started")
+
+ return jsonify({"status": "ok", "running": True})
+
+
+@drone_bp.route("/stop", methods=["POST"])
+def stop():
+ global _drone_running
+ if _remote_id_scanner:
+ _remote_id_scanner.stop()
+ if _rf_detector:
+ _rf_detector.stop()
+ _drone_running = False
+ logger.info("Drone detection stopped")
+ return jsonify({"status": "ok", "running": False})
+
+
+@drone_bp.route("/stream")
+def stream():
+ return Response(
+ sse_stream_fanout(
+ source_queue=app_module.drone_queue,
+ channel_key="drone",
+ timeout=SSE_QUEUE_TIMEOUT,
+ keepalive_interval=SSE_KEEPALIVE_INTERVAL,
+ ),
+ mimetype="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
diff --git a/tests/test_drone_routes.py b/tests/test_drone_routes.py
new file mode 100644
index 0000000..a7998f9
--- /dev/null
+++ b/tests/test_drone_routes.py
@@ -0,0 +1,63 @@
+import json
+import queue
+from unittest.mock import patch
+
+import pytest
+from flask import Flask
+
+import app as app_module
+from routes.drone import drone_bp
+
+
+@pytest.fixture(autouse=True)
+def mock_app_state(mocker):
+ mocker.patch.object(app_module, "drone_queue", queue.Queue())
+ yield
+
+
+@pytest.fixture
+def drone_app():
+ app = Flask(__name__)
+ app.register_blueprint(drone_bp)
+ app.config["TESTING"] = True
+ return app
+
+
+@pytest.fixture
+def client(drone_app):
+ return drone_app.test_client()
+
+
+def test_status_returns_json(client):
+ resp = client.get("/drone/status")
+ assert resp.status_code == 200
+ data = json.loads(resp.data)
+ assert "running" in data
+ assert "vectors" in data
+
+
+def test_contacts_returns_empty_list_when_idle(client):
+ resp = client.get("/drone/contacts")
+ assert resp.status_code == 200
+ data = json.loads(resp.data)
+ assert data == [] or isinstance(data, list)
+
+
+def test_start_returns_ok(client):
+ with (
+ patch("routes.drone._correlator"),
+ patch("routes.drone._remote_id_scanner"),
+ patch("routes.drone._rf_detector"),
+ ):
+ resp = client.post("/drone/start", json={})
+ assert resp.status_code == 200
+
+
+def test_stop_returns_ok(client):
+ resp = client.post("/drone/stop")
+ assert resp.status_code == 200
+
+
+def test_stream_returns_event_stream(client):
+ resp = client.get("/drone/stream")
+ assert resp.content_type.startswith("text/event-stream")
From 1a99a7213f41b8f7aba29b7837e3f235a79f25eb Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 17:39:23 +0100
Subject: [PATCH 11/14] fix(drone): add lock on _drone_running and null guards
before start() calls
Concurrent POST /drone/start under gevent would race on _drone_running;
lock mirrors the ais_lock / dsc_lock pattern used throughout the codebase.
Null guards prevent AttributeError if worker constructors fail.
Co-Authored-By: Claude Sonnet 4.6
---
routes/drone.py | 26 ++++++++++++++++----------
1 file changed, 16 insertions(+), 10 deletions(-)
diff --git a/routes/drone.py b/routes/drone.py
index 0d3ea67..43c41bf 100644
--- a/routes/drone.py
+++ b/routes/drone.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+import threading
from flask import Blueprint, Response, jsonify, request
@@ -21,6 +22,7 @@ _correlator: DroneCorrelator | None = None
_remote_id_scanner: RemoteIDScanner | None = None
_rf_detector: RFDetector | None = None
_drone_running = False
+_drone_lock = threading.Lock()
def _ensure_workers() -> None:
@@ -64,11 +66,14 @@ def start():
rtl_index = int((request.json or {}).get("rtl_sdr_index", 0))
use_hackrf = bool((request.json or {}).get("use_hackrf", True))
- if not _drone_running:
- _remote_id_scanner.start(wifi_iface=wifi_iface)
- _rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf)
- _drone_running = True
- logger.info("Drone detection started")
+ with _drone_lock:
+ if not _drone_running:
+ if _remote_id_scanner:
+ _remote_id_scanner.start(wifi_iface=wifi_iface)
+ if _rf_detector:
+ _rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf)
+ _drone_running = True
+ logger.info("Drone detection started")
return jsonify({"status": "ok", "running": True})
@@ -76,11 +81,12 @@ def start():
@drone_bp.route("/stop", methods=["POST"])
def stop():
global _drone_running
- if _remote_id_scanner:
- _remote_id_scanner.stop()
- if _rf_detector:
- _rf_detector.stop()
- _drone_running = False
+ with _drone_lock:
+ if _remote_id_scanner:
+ _remote_id_scanner.stop()
+ if _rf_detector:
+ _rf_detector.stop()
+ _drone_running = False
logger.info("Drone detection stopped")
return jsonify({"status": "ok", "running": False})
From e059be2d84a569775fd8a97433831696583a0837 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 17:41:02 +0100
Subject: [PATCH 12/14] feat(drone): add HTML partial, CSS, and index.html mode
panel wiring
- Create templates/partials/modes/drone.html with drone mode sidebar panel
- Create static/css/modes/drone.css with scoped drone UI styles
- Wire drone mode into index.html: CSS map entry, partial include, classList toggle
Co-Authored-By: Claude Sonnet 4.6
---
static/css/modes/drone.css | 74 +++++++++++++++++++++++++++++
templates/index.html | 6 ++-
templates/partials/modes/drone.html | 42 ++++++++++++++++
3 files changed, 121 insertions(+), 1 deletion(-)
create mode 100644 static/css/modes/drone.css
create mode 100644 templates/partials/modes/drone.html
diff --git a/static/css/modes/drone.css b/static/css/modes/drone.css
new file mode 100644
index 0000000..6453a92
--- /dev/null
+++ b/static/css/modes/drone.css
@@ -0,0 +1,74 @@
+/* Drone Intelligence Styles */
+
+.drone-vector-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.drone-vector-pill {
+ font-size: 10px;
+ font-family: var(--font-mono);
+ padding: 3px 8px;
+ border-radius: 3px;
+ background: var(--bg-primary);
+ color: var(--text-dim);
+ border: 1px solid var(--border-color);
+ transition: background 0.2s, color 0.2s;
+}
+
+.drone-vector-pill.active {
+ background: color-mix(in srgb, var(--accent-cyan) 15%, transparent);
+ color: var(--accent-cyan);
+ border-color: var(--accent-cyan);
+}
+
+.drone-contact-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 10px 12px;
+ margin-bottom: 8px;
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+
+.drone-contact-card:hover {
+ border-color: var(--accent-cyan);
+}
+
+.drone-contact-card.high-risk {
+ border-left: 3px solid var(--accent-red);
+}
+
+.drone-contact-card.medium-risk {
+ border-left: 3px solid var(--accent-yellow);
+}
+
+.drone-contact-card.low-risk {
+ border-left: 3px solid var(--accent-green);
+}
+
+.drone-compliance-badge {
+ font-size: 9px;
+ font-family: var(--font-mono);
+ padding: 2px 6px;
+ border-radius: 2px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.drone-compliance-badge.compliant {
+ background: color-mix(in srgb, var(--accent-green) 20%, transparent);
+ color: var(--accent-green);
+}
+
+.drone-compliance-badge.non-compliant {
+ background: color-mix(in srgb, var(--accent-red) 20%, transparent);
+ color: var(--accent-red);
+}
+
+.drone-marker-high-risk {
+ animation: dsc-distress-pulse 1.5s infinite;
+}
diff --git a/templates/index.html b/templates/index.html
index a7640bb..0caf6f3 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -102,7 +102,8 @@
radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}",
meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}",
system: "{{ url_for('static', filename='css/modes/system.css') }}",
- ook: "{{ url_for('static', filename='css/modes/ook.css') }}"
+ ook: "{{ url_for('static', filename='css/modes/ook.css') }}",
+ drone: "{{ url_for('static', filename='css/modes/drone.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.INTERCEPT_MODE_STYLE_PROMISES = {};
@@ -764,6 +765,8 @@
{% include 'partials/modes/ais.html' %}
+ {% include 'partials/modes/drone.html' %}
+
{% include 'partials/modes/radiosonde.html' %}
{% include 'partials/modes/spy-stations.html' %}
@@ -4625,6 +4628,7 @@
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
+ document.getElementById('droneMode')?.classList.toggle('active', mode === 'drone');
document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
diff --git a/templates/partials/modes/drone.html b/templates/partials/modes/drone.html
new file mode 100644
index 0000000..e63a860
--- /dev/null
+++ b/templates/partials/modes/drone.html
@@ -0,0 +1,42 @@
+
+
+
+
Drone Intelligence
+
+ Multi-vector UAV detection: Remote ID (WiFi/BLE), 433/868 MHz control links, 2.4/5.8 GHz wideband.
+
+
+
+
+
Detection Vectors
+
+ Remote ID
+ 433 MHz
+ 2.4 / 5.8 GHz
+
+
+
+
+
WiFi Interface (monitor mode)
+
+
+
+
+
+
+
Status
+
+ Status: Standby
+
+
+ Contacts: 0
+ |
+ Non-compliant: 0
+
+
+
From 14e6305aa4a37a62c828e6fdb64f509c21358279 Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 17:44:07 +0100
Subject: [PATCH 13/14] feat(drone): add frontend JS, modeCatalog entry, and
switchMode wiring
Creates static/js/modes/drone.js IIFE module (SSE consumer, map markers,
contact cards, start/stop controls) and wires it into index.html via
INTERCEPT_MODE_SCRIPT_MAP, modeCatalog (intel group), and switchMode
init/destroy handlers.
Co-Authored-By: Claude Sonnet 4.6
---
static/js/modes/drone.js | 189 +++++++++++++++++++++++++++++++++++++++
templates/index.html | 7 +-
2 files changed, 195 insertions(+), 1 deletion(-)
create mode 100644 static/js/modes/drone.js
diff --git a/static/js/modes/drone.js b/static/js/modes/drone.js
new file mode 100644
index 0000000..55af9d8
--- /dev/null
+++ b/static/js/modes/drone.js
@@ -0,0 +1,189 @@
+(function DroneMode() {
+ 'use strict';
+
+ let _sse = null;
+ let _map = null;
+ let _markers = {};
+ let _trails = {};
+ let _running = false;
+
+ function init() {
+ document.getElementById('droneStartBtn')?.addEventListener('click', _start);
+ document.getElementById('droneStopBtn')?.addEventListener('click', _stop);
+ _connectSSE();
+ _refreshStatus();
+ }
+
+ function destroy() {
+ _disconnectSSE();
+ if (_map) {
+ _map.remove();
+ _map = null;
+ }
+ _markers = {};
+ _trails = {};
+ }
+
+ function _connectSSE() {
+ if (_sse) return;
+ _sse = new EventSource('/drone/stream');
+ _sse.addEventListener('message', function (e) {
+ try {
+ const msg = JSON.parse(e.data);
+ if (msg.type === 'contact') _handleContact(msg.data);
+ } catch (_) {}
+ });
+ _sse.onerror = function () {
+ setTimeout(_connectSSE, 3000);
+ };
+ }
+
+ function _disconnectSSE() {
+ if (_sse) { _sse.close(); _sse = null; }
+ }
+
+ function _handleContact(contact) {
+ _upsertCard(contact);
+ if (contact.position) _upsertMapMarker(contact);
+ _updateStats();
+ }
+
+ function _upsertCard(contact) {
+ const listEl = document.getElementById('droneContactList');
+ if (!listEl) return;
+ let card = document.getElementById('drone-card-' + contact.id);
+ if (!card) {
+ card = document.createElement('div');
+ card.id = 'drone-card-' + contact.id;
+ card.className = 'drone-contact-card';
+ card.addEventListener('click', function () { _focusContact(contact.id); });
+ listEl.prepend(card);
+ }
+ card.className = 'drone-contact-card ' + contact.risk_level + '-risk';
+ const complianceLabel = contact.compliant
+ ? 'Remote ID'
+ : 'No Remote ID';
+ const vectors = (contact.detection_vectors || []).map(function (v) {
+ return '' + v + '';
+ }).join('');
+ const alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + 'm' : '—';
+ const spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + 'm/s' : '—';
+ card.innerHTML = [
+ '',
+ ' ' + (contact.serial_number || contact.id) + '',
+ ' ' + complianceLabel,
+ '
',
+ '' + vectors + '
',
+ 'Alt: ' + alt + ' Speed: ' + spd + '
',
+ ].join('');
+ }
+
+ function _upsertMapMarker(contact) {
+ if (!_map) return;
+ const lat = contact.position[0];
+ const lon = contact.position[1];
+ if (_markers[contact.id]) {
+ _markers[contact.id].setLatLng([lat, lon]);
+ } else {
+ const color = contact.risk_level === 'high' ? 'var(--accent-red)' :
+ contact.risk_level === 'medium' ? 'var(--accent-yellow)' :
+ 'var(--accent-cyan)';
+ const icon = L.divIcon({
+ className: 'drone-map-icon' + (contact.risk_level === 'high' ? ' drone-marker-high-risk' : ''),
+ html: '',
+ iconSize: [10, 10],
+ iconAnchor: [5, 5],
+ });
+ _markers[contact.id] = L.marker([lat, lon], { icon: icon })
+ .addTo(_map)
+ .bindPopup('' + (contact.serial_number || contact.id) + '
Risk: ' + contact.risk_level);
+ }
+ const trailPoints = (contact.position_history || []).map(function (p) {
+ return [p.lat, p.lon];
+ });
+ if (_trails[contact.id]) {
+ _trails[contact.id].setLatLngs(trailPoints);
+ } else if (trailPoints.length > 1) {
+ _trails[contact.id] = L.polyline(trailPoints, {
+ color: contact.risk_level === 'high' ? '#ff4444' : '#00ccff',
+ weight: 1.5,
+ opacity: 0.6,
+ }).addTo(_map);
+ }
+ }
+
+ function _focusContact(contactId) {
+ if (_map && _markers[contactId]) {
+ _map.panTo(_markers[contactId].getLatLng());
+ _markers[contactId].openPopup();
+ }
+ }
+
+ function _updateStats() {
+ fetch('/drone/contacts')
+ .then(function (r) { return r.json(); })
+ .then(function (contacts) {
+ const nonCompliant = contacts.filter(function (c) { return !c.compliant; }).length;
+ const countEl = document.getElementById('droneContactCount');
+ const ncEl = document.getElementById('droneNonCompliantCount');
+ if (countEl) countEl.textContent = contacts.length;
+ if (ncEl) ncEl.textContent = nonCompliant;
+ })
+ .catch(function () {});
+ }
+
+ function _refreshStatus() {
+ fetch('/drone/status')
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ _running = data.running;
+ _setRunningUI(data.running);
+ _updateVectorPills(data.vectors || []);
+ })
+ .catch(function () {});
+ }
+
+ function _start() {
+ const iface = document.getElementById('droneWifiIface')?.value.trim() || null;
+ fetch('/drone/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ wifi_iface: iface }),
+ })
+ .then(function (r) { return r.json(); })
+ .then(function () { _setRunningUI(true); _refreshStatus(); })
+ .catch(function () {});
+ }
+
+ function _stop() {
+ fetch('/drone/stop', { method: 'POST' })
+ .then(function () { _setRunningUI(false); _refreshStatus(); })
+ .catch(function () {});
+ }
+
+ function _setRunningUI(running) {
+ const startBtn = document.getElementById('droneStartBtn');
+ const stopBtn = document.getElementById('droneStopBtn');
+ const statusEl = document.getElementById('droneStatusText');
+ if (startBtn) startBtn.disabled = running;
+ if (stopBtn) stopBtn.disabled = !running;
+ if (statusEl) {
+ statusEl.textContent = running ? 'Active' : 'Standby';
+ statusEl.style.color = running ? 'var(--accent-green)' : 'var(--accent-yellow)';
+ }
+ }
+
+ function _updateVectorPills(activeVectors) {
+ const pillMap = {
+ 'REMOTE_ID': 'dronePillRemoteId',
+ 'RTL433': 'dronePill433',
+ 'HACKRF': 'dronePillHackrf',
+ };
+ Object.entries(pillMap).forEach(function ([key, id]) {
+ const el = document.getElementById(id);
+ if (el) el.classList.toggle('active', activeVectors.some(function (v) { return v.includes(key); }));
+ });
+ }
+
+ window.DroneMode = { init: init, destroy: destroy };
+})();
diff --git a/templates/index.html b/templates/index.html
index 0caf6f3..afad27b 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -187,7 +187,8 @@
spaceweather: "{{ url_for('static', filename='js/modes/space-weather.js') }}",
system: "{{ url_for('static', filename='js/modes/system.js') }}",
meteor: "{{ url_for('static', filename='js/modes/meteor.js') }}",
- waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"
+ waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21",
+ drone: "{{ url_for('static', filename='js/modes/drone.js') }}"
};
window.INTERCEPT_MODE_SCRIPT_LOADED = {};
window.INTERCEPT_MODE_SCRIPT_PROMISES = {};
@@ -3692,6 +3693,7 @@
wifi_locate: { label: 'WiFi Locate', indicator: 'WF LOCATE', outputTitle: 'WiFi Locate', group: 'wireless' },
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
+ drone: { label: 'Drone Intel', indicator: 'DRONE', outputTitle: 'Drone Intelligence', group: 'intel' },
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
@@ -4318,6 +4320,7 @@
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
+ drone: () => typeof DroneMode !== 'undefined' && DroneMode.destroy?.(),
};
return moduleDestroyMap[mode] || null;
}
@@ -4927,6 +4930,8 @@
SystemHealth.init();
} else if (mode === 'ook') {
OokMode.init();
+ } else if (mode === 'drone') {
+ if (typeof DroneMode !== 'undefined') DroneMode.init();
}
if (requestId !== modeSwitchRequestId) return;
From 8632e31c011c4deb3972ca39e6f6b707dc7dbb0c Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 21:47:12 +0100
Subject: [PATCH 14/14] fix(drone): resolve critical pipeline, frontend, and
input validation issues
Data pipeline (critical): scanners/detectors now write to a separate _obs_queue;
a relay thread reads observations and calls correlator.process(), which emits
processed DroneContact dicts to drone_queue for SSE. Without this the SSE stream
received raw unserializable dataclass objects causing JSON errors.
Frontend (critical):
- Add droneContactList container to drone.html so contact cards render
- Add droneMap container and initialize Leaflet in drone.js init()
- Define dsc-distress-pulse keyframes in drone.css (was referenced but missing)
- Fix SSE reconnect: null _sse before setTimeout to prevent _connectSSE no-op loop
Other fixes:
- Validate rtl_sdr_index with validate_device_index(), return 400 on bad input
- Move _ensure_workers() inside _drone_lock to prevent double-initialization race
- Add double-call guard to RemoteIDScanner.start()
Co-Authored-By: Claude Sonnet 4.6
---
routes/drone.py | 41 ++++++++++++++++++++++++-----
static/css/modes/drone.css | 12 +++++++++
static/js/modes/drone.js | 14 ++++++++++
templates/partials/modes/drone.html | 7 +++++
utils/drone/remote_id.py | 2 ++
5 files changed, 69 insertions(+), 7 deletions(-)
diff --git a/routes/drone.py b/routes/drone.py
index 43c41bf..8303738 100644
--- a/routes/drone.py
+++ b/routes/drone.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+import queue
import threading
from flask import Blueprint, Response, jsonify, request
@@ -13,6 +14,7 @@ from utils.drone.correlator import DroneCorrelator
from utils.drone.remote_id import RemoteIDScanner
from utils.drone.rf_detector import RFDetector
from utils.sse import sse_stream_fanout
+from utils.validation import validate_device_index
logger = logging.getLogger("intercept.drone")
@@ -21,18 +23,37 @@ drone_bp = Blueprint("drone", __name__, url_prefix="/drone")
_correlator: DroneCorrelator | None = None
_remote_id_scanner: RemoteIDScanner | None = None
_rf_detector: RFDetector | None = None
+_obs_queue: queue.Queue | None = None # raw observations from scanners/detectors
+_relay_thread: threading.Thread | None = None
_drone_running = False
_drone_lock = threading.Lock()
+_SENTINEL = object()
+
+
+def _relay_observations() -> None:
+ """Read raw observations from _obs_queue and feed them into the correlator."""
+ while True:
+ obs = _obs_queue.get()
+ if obs is _SENTINEL:
+ break
+ if _correlator is not None:
+ _correlator.process(obs)
+
def _ensure_workers() -> None:
- global _correlator, _remote_id_scanner, _rf_detector
+ global _correlator, _remote_id_scanner, _rf_detector, _obs_queue, _relay_thread
+ if _obs_queue is None:
+ _obs_queue = queue.Queue(maxsize=512)
if _correlator is None:
_correlator = DroneCorrelator(output_queue=app_module.drone_queue)
if _remote_id_scanner is None:
- _remote_id_scanner = RemoteIDScanner(output_queue=app_module.drone_queue)
+ _remote_id_scanner = RemoteIDScanner(output_queue=_obs_queue)
if _rf_detector is None:
- _rf_detector = RFDetector(output_queue=app_module.drone_queue)
+ _rf_detector = RFDetector(output_queue=_obs_queue)
+ if _relay_thread is None or not _relay_thread.is_alive():
+ _relay_thread = threading.Thread(target=_relay_observations, daemon=True)
+ _relay_thread.start()
@drone_bp.route("/status")
@@ -61,12 +82,16 @@ def contacts():
@drone_bp.route("/start", methods=["POST"])
def start():
global _drone_running
- _ensure_workers()
- wifi_iface = request.json.get("wifi_iface") if request.json else None
- rtl_index = int((request.json or {}).get("rtl_sdr_index", 0))
- use_hackrf = bool((request.json or {}).get("use_hackrf", True))
+ body = request.json or {}
+ wifi_iface = body.get("wifi_iface") or None
+ try:
+ rtl_index = validate_device_index(body.get("rtl_sdr_index", 0))
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+ use_hackrf = bool(body.get("use_hackrf", True))
with _drone_lock:
+ _ensure_workers()
if not _drone_running:
if _remote_id_scanner:
_remote_id_scanner.start(wifi_iface=wifi_iface)
@@ -86,6 +111,8 @@ def stop():
_remote_id_scanner.stop()
if _rf_detector:
_rf_detector.stop()
+ if _obs_queue is not None:
+ _obs_queue.put_nowait(_SENTINEL)
_drone_running = False
logger.info("Drone detection stopped")
return jsonify({"status": "ok", "running": False})
diff --git a/static/css/modes/drone.css b/static/css/modes/drone.css
index 6453a92..1e57e71 100644
--- a/static/css/modes/drone.css
+++ b/static/css/modes/drone.css
@@ -69,6 +69,18 @@
color: var(--accent-red);
}
+.drone-map {
+ height: 280px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+ margin: 0 12px 12px;
+}
+
.drone-marker-high-risk {
animation: dsc-distress-pulse 1.5s infinite;
}
+
+@keyframes dsc-distress-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.4; transform: scale(1.4); }
+}
diff --git a/static/js/modes/drone.js b/static/js/modes/drone.js
index 55af9d8..d7b7777 100644
--- a/static/js/modes/drone.js
+++ b/static/js/modes/drone.js
@@ -10,10 +10,22 @@
function init() {
document.getElementById('droneStartBtn')?.addEventListener('click', _start);
document.getElementById('droneStopBtn')?.addEventListener('click', _stop);
+ _initMap();
_connectSSE();
_refreshStatus();
}
+ function _initMap() {
+ if (_map) return;
+ const mapEl = document.getElementById('droneMap');
+ if (!mapEl || typeof L === 'undefined') return;
+ _map = L.map('droneMap', { zoomControl: true }).setView([20, 0], 2);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap',
+ maxZoom: 18,
+ }).addTo(_map);
+ }
+
function destroy() {
_disconnectSSE();
if (_map) {
@@ -34,6 +46,8 @@
} catch (_) {}
});
_sse.onerror = function () {
+ _sse.close();
+ _sse = null;
setTimeout(_connectSSE, 3000);
};
}
diff --git a/templates/partials/modes/drone.html b/templates/partials/modes/drone.html
index e63a860..c39babf 100644
--- a/templates/partials/modes/drone.html
+++ b/templates/partials/modes/drone.html
@@ -39,4 +39,11 @@
Non-compliant: 0
+
+
+
+
diff --git a/utils/drone/remote_id.py b/utils/drone/remote_id.py
index a4143db..778e0de 100644
--- a/utils/drone/remote_id.py
+++ b/utils/drone/remote_id.py
@@ -98,6 +98,8 @@ class RemoteIDScanner:
elt = elt.payload if hasattr(elt, "payload") and isinstance(elt.payload, Dot11Elt) else None
def start(self, wifi_iface: str | None = None) -> None:
+ if self._running:
+ return
self._running = True
if SCAPY_AVAILABLE and wifi_iface:
try: