From 58222b34745e9838371e186fcb5fa07567196c68 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 3 May 2026 09:24:22 +0100 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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 @@ + + From 14e6305aa4a37a62c828e6fdb64f509c21358279 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 3 May 2026 17:44:07 +0100 Subject: [PATCH 13/17] 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/17] 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

+ +
+

Detected Contacts

+
+
+ +
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: From 3b480eb183f219b279dd83ad84aad5537e8e4e2e Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 3 May 2026 09:24:22 +0100 Subject: [PATCH 15/17] 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 d033a95b0e3535f1b603efea3ed3afdf70a191d8 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 3 May 2026 11:13:07 +0100 Subject: [PATCH 16/17] 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 62e53c5dfa2a2b8645a521549b6d5320347f140f Mon Sep 17 00:00:00 2001 From: James Smith Date: Tue, 5 May 2026 09:24:30 +0100 Subject: [PATCH 17/17] fix(adsb): fix aircraft photo display and add Drone Intelligence docs - Fix stale DOM refs in fetchAircraftPhoto: elements were captured before await fetch(), but showAircraftDetails rebuilds innerHTML on every RAF update, leaving the async path writing to detached nodes. Now re-queries the DOM after await, and the cache (synchronous) path queries inline so refs are always fresh. - Add thumbnail fallback in aircraft_photo route: fall back to thumbnail when thumbnail_large.src is absent rather than returning null. - Add Drone Intelligence to nav, help modal, cheat sheets, README, and docs. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + docs/FEATURES.md | 36 ++ docs/USAGE.md | 29 + docs/index.html | 7 +- routes/adsb.py | 999 ++++++++++++++++------------- static/js/core/cheat-sheets.js | 9 +- templates/adsb_dashboard.html | 30 +- templates/partials/help-modal.html | 11 + templates/partials/nav.html | 1 + 9 files changed, 654 insertions(+), 469 deletions(-) diff --git a/README.md b/README.md index 3c4c903..c412bcc 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Support the developer of this open-source project - **Spy Stations** - Number stations and diplomatic HF network database - **Remote Agents** - Distributed SIGINT with remote sensor nodes - **Offline Mode** - Bundled assets for air-gapped/field deployments +- **Drone Intelligence** - Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cd5bad0..20a8459 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -354,6 +354,42 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s - No cryptographic de-randomization - Passive screening only (no active probing by default) +## Drone Intelligence + +Multi-vector UAV detection and identification system combining three complementary detection methods into unified contact tracking. + +### Detection Vectors + +- **Remote ID (WiFi/BLE)** β€” Parses ASTM F3411-22a broadcast frames from WiFi Beacon and BLE Advertisement packets. Extracts drone ID, operator ID, drone type, GPS position, altitude, speed, and emergency status. Mandatory for all drones >250g in the US/EU since 2023. +- **RTL-SDR RF (433/868 MHz)** β€” Monitors ISM bands for control link and telemetry signals characteristic of consumer and FPV drones. Detects DJI OcuSync, FrSky, FlySky, and generic FSK/GFSK drone control protocols. +- **HackRF (2.4/5.8 GHz)** β€” Wide-scan of video downlink and telemetry bands used by most consumer drones. Detects power above noise floor across 2.400–2.483 GHz and 5.725–5.875 GHz ISM bands. + +### Contact Correlation + +The `DroneCorrelator` merges raw observations from all three vectors into unified `DroneContact` objects: +- **TTL-based store** β€” contacts expire after 120 seconds of no activity +- **Multi-vector fusion** β€” a single contact can be seen on 1–3 vectors simultaneously +- **Deduplication** β€” observations from the same vector within 5 seconds are collapsed + +### Risk Scoring + +| Level | Criteria | +|-------|----------| +| High | No Remote ID broadcast (non-compliant) or ASTM non-conformant frame | +| Medium | Multiple detection vectors active, or RSSI delta >15 dB between vectors | +| Low | Compliant Remote ID present, single detection vector | + +### Live Map + +Remote ID contacts with GPS position data are plotted on a Leaflet map. Markers show drone ID and last known coordinates. Map updates in real time via SSE. + +### Requirements + +- WiFi adapter capable of monitor mode (for BLE/WiFi Remote ID) +- RTL-SDR dongle (for 433/868 MHz RF detection) +- HackRF One (optional, for 2.4/5.8 GHz detection) +- Python package: `opendroneid>=1.0` + ## Meshtastic Mesh Networks Integration with Meshtastic LoRa mesh networking devices for decentralized communication. diff --git a/docs/USAGE.md b/docs/USAGE.md index 963200c..ad4c3cc 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -446,6 +446,35 @@ Digital Selective Calling monitoring runs alongside AIS: - Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware - Threat detection uses a database of 47K+ known tracker fingerprints +## Drone Intelligence + +1. **Open Mode** - Select "Drone Intel" from the Intel group in the navigation bar +2. **Configure Interfaces** - Enter your WiFi interface name (must support monitor mode) for Remote ID detection +3. **Set RTL-SDR Index** - If you have multiple RTL-SDR devices, enter the device index (default: 0) +4. **Start** - Click "Start Scan" to activate all available detection vectors simultaneously +5. **Monitor Contacts** - Detected drone contacts appear in the contact list with ID, vectors, risk level, and last seen time +6. **View Map** - Contacts with GPS data from Remote ID are plotted on the live map + +### Detection Vectors + +- **Remote ID (WiFi/BLE)** β€” Passive sniff of 802.11 beacon frames and BLE advertisements. Decodes ASTM F3411 payloads: drone GPS, operator ID, drone type, speed, altitude, and emergency status +- **433/868 MHz RF** β€” RTL-SDR scans ISM bands for drone control link and telemetry RF signatures +- **2.4/5.8 GHz** β€” HackRF (if present) sweeps video downlink bands for active drone transmissions + +### Risk Levels + +- **High** β€” Drone operating without Remote ID (non-compliant) or malformed ASTM frame. Warrants immediate attention. +- **Medium** β€” Contact detected on multiple RF vectors, or significant RSSI difference between vectors (>15 dB). May indicate evasion or multi-radio platform. +- **Low** β€” Compliant Remote ID broadcast, single detection vector. Standard consumer drone. + +### Tips + +- Remote ID is mandatory for drones >250g in the US (FAA) and EU (EU 2019/945) β€” absence of Remote ID is itself a significant indicator +- WiFi adapter must support monitor mode; run `airmon-ng check kill` if other processes interfere +- The contact map only shows drones that broadcast GPS coordinates via Remote ID +- Contacts expire after 120 seconds of inactivity β€” the list shows only currently active drones +- HackRF detection is passive (receive-only); no transmission occurs + ## Spy Stations 1. **Browse Database** - View the full list of documented number stations and diplomatic networks diff --git a/docs/index.html b/docs/index.html index 14c15f5..0d90a35 100644 --- a/docs/index.html +++ b/docs/index.html @@ -36,7 +36,7 @@
- 34 + 35 Modes
@@ -202,6 +202,11 @@

TSCM

Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.

+
+
+

Drone Intelligence

+

Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring.

+

Meshtastic

diff --git a/routes/adsb.py b/routes/adsb.py index 430347b..438fe0d 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -23,6 +23,7 @@ from utils.responses import api_error, api_success try: import psycopg2 from psycopg2.extras import RealDictCursor + PSYCOPG2_AVAILABLE = True except ImportError: psycopg2 = None # type: ignore @@ -68,7 +69,7 @@ from utils.sdr import SDRFactory, SDRType from utils.sse import format_sse from utils.validation import validate_device_index, validate_gain, validate_rtl_tcp_host, validate_rtl_tcp_port -adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb') +adsb_bp = Blueprint("adsb", __name__, url_prefix="/adsb") # Track if using service adsb_using_service = False @@ -96,17 +97,17 @@ aircraft_db.load_database() # Common installation paths for dump1090 (when not in PATH) DUMP1090_PATHS = [ # Homebrew on Apple Silicon (M1/M2/M3) - '/opt/homebrew/bin/dump1090', - '/opt/homebrew/bin/dump1090-fa', - '/opt/homebrew/bin/dump1090-mutability', + "/opt/homebrew/bin/dump1090", + "/opt/homebrew/bin/dump1090-fa", + "/opt/homebrew/bin/dump1090-mutability", # Homebrew on Intel Mac - '/usr/local/bin/dump1090', - '/usr/local/bin/dump1090-fa', - '/usr/local/bin/dump1090-mutability', + "/usr/local/bin/dump1090", + "/usr/local/bin/dump1090-fa", + "/usr/local/bin/dump1090-mutability", # Linux system paths - '/usr/bin/dump1090', - '/usr/bin/dump1090-fa', - '/usr/bin/dump1090-mutability', + "/usr/bin/dump1090", + "/usr/bin/dump1090-fa", + "/usr/bin/dump1090-mutability", ] @@ -158,24 +159,24 @@ def _build_history_record( raw_line: str, ) -> dict[str, Any]: return { - 'received_at': datetime.now(timezone.utc), - 'msg_time': msg_time, - 'logged_time': logged_time, - 'icao': icao, - 'msg_type': _parse_int(msg_type), - 'callsign': _get_part(parts, 10), - 'altitude': _parse_int(_get_part(parts, 11)), - 'speed': _parse_int(_get_part(parts, 12)), - 'heading': _parse_int(_get_part(parts, 13)), - 'vertical_rate': _parse_int(_get_part(parts, 16)), - 'lat': _parse_float(_get_part(parts, 14)), - 'lon': _parse_float(_get_part(parts, 15)), - 'squawk': _get_part(parts, 17), - 'session_id': _get_part(parts, 2), - 'aircraft_id': _get_part(parts, 3), - 'flight_id': _get_part(parts, 5), - 'raw_line': raw_line, - 'source_host': service_addr, + "received_at": datetime.now(timezone.utc), + "msg_time": msg_time, + "logged_time": logged_time, + "icao": icao, + "msg_type": _parse_int(msg_type), + "callsign": _get_part(parts, 10), + "altitude": _parse_int(_get_part(parts, 11)), + "speed": _parse_int(_get_part(parts, 12)), + "heading": _parse_int(_get_part(parts, 13)), + "vertical_rate": _parse_int(_get_part(parts, 16)), + "lat": _parse_float(_get_part(parts, 14)), + "lon": _parse_float(_get_part(parts, 15)), + "squawk": _get_part(parts, 17), + "session_id": _get_part(parts, 2), + "aircraft_id": _get_part(parts, 3), + "flight_id": _get_part(parts, 5), + "raw_line": raw_line, + "source_host": service_addr, } @@ -214,10 +215,37 @@ MILITARY_ICAO_RANGES = [ ] MILITARY_CALLSIGN_PREFIXES = ( - 'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER', - 'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE', - 'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK', - 'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF', + "REACH", + "JAKE", + "DOOM", + "IRON", + "HAWK", + "VIPER", + "COBRA", + "THUNDER", + "SHADOW", + "NIGHT", + "STEEL", + "GRIM", + "REAPER", + "BLADE", + "STRIKE", + "RCH", + "CNV", + "MCH", + "EVAC", + "TOPCAT", + "ASCOT", + "RRR", + "HRK", + "NAVY", + "ARMY", + "USAF", + "RAF", + "RCAF", + "RAAF", + "IAF", + "PAF", ) @@ -238,7 +266,9 @@ def _is_military_aircraft(icao: str, callsign: str | None) -> bool: return False -def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int: +def _parse_int_param( + value: str | None, default: int, min_value: int | None = None, max_value: int | None = None +) -> int: try: parsed = int(value) if value is not None else default except (ValueError, TypeError): @@ -256,7 +286,7 @@ def _parse_iso_datetime(value: Any) -> datetime | None: cleaned = value.strip() if not cleaned: return None - if cleaned.endswith('Z'): + if cleaned.endswith("Z"): cleaned = f"{cleaned[:-1]}+00:00" try: parsed = datetime.fromisoformat(cleaned) @@ -270,14 +300,14 @@ def _parse_iso_datetime(value: Any) -> datetime | None: def _parse_export_scope( args: Any, ) -> tuple[str, int, datetime | None, datetime | None]: - scope = str(args.get('scope') or 'window').strip().lower() - if scope not in {'window', 'all', 'custom'}: - scope = 'window' - since_minutes = _parse_int_param(args.get('since_minutes'), 1440, 1, 525600) - start = _parse_iso_datetime(args.get('start')) - end = _parse_iso_datetime(args.get('end')) - if scope == 'custom' and (start is None or end is None or end <= start): - scope = 'window' + scope = str(args.get("scope") or "window").strip().lower() + if scope not in {"window", "all", "custom"}: + scope = "window" + since_minutes = _parse_int_param(args.get("since_minutes"), 1440, 1, 525600) + start = _parse_iso_datetime(args.get("start")) + end = _parse_iso_datetime(args.get("end")) + if scope == "custom" and (start is None or end is None or end <= start): + scope = "window" return scope, since_minutes, start, end @@ -291,14 +321,14 @@ def _add_time_filter( start: datetime | None, end: datetime | None, ) -> None: - if scope == 'all': + if scope == "all": return - if scope == 'custom' and start is not None and end is not None: + if scope == "custom" and start is not None and end is not None: where_parts.append(f"{timestamp_field} >= %s AND {timestamp_field} < %s") params.extend([start, end]) return where_parts.append(f"{timestamp_field} >= NOW() - INTERVAL %s") - params.append(f'{since_minutes} minutes') + params.append(f"{since_minutes} minutes") def _serialize_export_value(value: Any) -> Any: @@ -327,16 +357,16 @@ def _build_export_csv( output = io.StringIO() writer = csv.writer(output) - writer.writerow(['Exported At', exported_at]) - writer.writerow(['Scope', scope]) + writer.writerow(["Exported At", exported_at]) + writer.writerow(["Scope", scope]) if since_minutes is not None: - writer.writerow(['Since Minutes', since_minutes]) + writer.writerow(["Since Minutes", since_minutes]) if icao: - writer.writerow(['ICAO Filter', icao]) + writer.writerow(["ICAO Filter", icao]) if search: - writer.writerow(['Search Filter', search]) - if classification != 'all': - writer.writerow(['Classification', classification]) + writer.writerow(["Search Filter", search]) + if classification != "all": + writer.writerow(["Classification", classification]) writer.writerow([]) def write_section(title: str, rows: list[dict[str, Any]], columns: list[str]) -> None: @@ -346,35 +376,71 @@ def _build_export_csv( writer.writerow([_serialize_export_value(row.get(col)) for col in columns]) writer.writerow([]) - if export_type in {'messages', 'all'}: + if export_type in {"messages", "all"}: write_section( - 'Messages', + "Messages", messages, [ - 'received_at', 'msg_time', 'logged_time', 'icao', 'msg_type', 'callsign', - 'altitude', 'speed', 'heading', 'vertical_rate', 'lat', 'lon', 'squawk', - 'session_id', 'aircraft_id', 'flight_id', 'source_host', 'raw_line', + "received_at", + "msg_time", + "logged_time", + "icao", + "msg_type", + "callsign", + "altitude", + "speed", + "heading", + "vertical_rate", + "lat", + "lon", + "squawk", + "session_id", + "aircraft_id", + "flight_id", + "source_host", + "raw_line", ], ) - if export_type in {'snapshots', 'all'}: + if export_type in {"snapshots", "all"}: write_section( - 'Snapshots', + "Snapshots", snapshots, [ - 'captured_at', 'icao', 'callsign', 'registration', 'type_code', 'type_desc', - 'altitude', 'speed', 'heading', 'vertical_rate', 'lat', 'lon', 'squawk', - 'source_host', + "captured_at", + "icao", + "callsign", + "registration", + "type_code", + "type_desc", + "altitude", + "speed", + "heading", + "vertical_rate", + "lat", + "lon", + "squawk", + "source_host", ], ) - if export_type in {'sessions', 'all'}: + if export_type in {"sessions", "all"}: write_section( - 'Sessions', + "Sessions", sessions, [ - 'id', 'started_at', 'ended_at', 'device_index', 'sdr_type', 'remote_host', - 'remote_port', 'start_source', 'stop_source', 'started_by', 'stopped_by', 'notes', + "id", + "started_at", + "ended_at", + "device_index", + "sdr_type", + "remote_host", + "remote_port", + "start_source", + "stop_source", + "started_by", + "stopped_by", + "notes", ], ) @@ -491,10 +557,11 @@ def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) -> logger.warning("ADS-B session stop record failed: %s", exc) return None + def find_dump1090(): """Find dump1090 binary, checking PATH and common locations.""" # First try PATH - for name in ['dump1090', 'dump1090-mutability', 'dump1090-fa']: + for name in ["dump1090", "dump1090-mutability", "dump1090-fa"]: path = shutil.which(name) if path: return path @@ -510,10 +577,10 @@ def check_dump1090_service(): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(SOCKET_CONNECT_TIMEOUT) - result = sock.connect_ex(('localhost', ADSB_SBS_PORT)) + result = sock.connect_ex(("localhost", ADSB_SBS_PORT)) sock.close() if result == 0: - return f'localhost:{ADSB_SBS_PORT}' + return f"localhost:{ADSB_SBS_PORT}" except OSError: pass return None @@ -521,12 +588,19 @@ def check_dump1090_service(): def parse_sbs_stream(service_addr): """Parse SBS format data from dump1090 SBS port.""" - global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged + global \ + adsb_using_service, \ + adsb_connected, \ + adsb_messages_received, \ + adsb_last_message_time, \ + adsb_bytes_received, \ + adsb_lines_received, \ + _sbs_error_logged adsb_history_writer.start() adsb_snapshot_writer.start() - host, port = service_addr.split(':') + host, port = service_addr.split(":") port = int(port) logger.info(f"SBS stream parser started, connecting to {host}:{port}") @@ -563,38 +637,41 @@ def parse_sbs_stream(service_addr): for update_icao in tuple(pending_updates): if update_icao in app_module.adsb_aircraft: snapshot = app_module.adsb_aircraft[update_icao] - _broadcast_adsb_update({ - 'type': 'aircraft', - **snapshot - }) - adsb_snapshot_writer.enqueue({ - 'captured_at': captured_at, - 'icao': update_icao, - 'callsign': snapshot.get('callsign'), - 'registration': snapshot.get('registration'), - 'type_code': snapshot.get('type_code'), - 'type_desc': snapshot.get('type_desc'), - 'altitude': snapshot.get('altitude'), - 'speed': snapshot.get('speed'), - 'heading': snapshot.get('heading'), - 'vertical_rate': snapshot.get('vertical_rate'), - 'lat': snapshot.get('lat'), - 'lon': snapshot.get('lon'), - 'squawk': snapshot.get('squawk'), - 'source_host': service_addr, - 'snapshot': snapshot, - }) + _broadcast_adsb_update({"type": "aircraft", **snapshot}) + adsb_snapshot_writer.enqueue( + { + "captured_at": captured_at, + "icao": update_icao, + "callsign": snapshot.get("callsign"), + "registration": snapshot.get("registration"), + "type_code": snapshot.get("type_code"), + "type_desc": snapshot.get("type_desc"), + "altitude": snapshot.get("altitude"), + "speed": snapshot.get("speed"), + "heading": snapshot.get("heading"), + "vertical_rate": snapshot.get("vertical_rate"), + "lat": snapshot.get("lat"), + "lon": snapshot.get("lon"), + "squawk": snapshot.get("squawk"), + "source_host": service_addr, + "snapshot": snapshot, + } + ) # Geofence check - _gf_lat = snapshot.get('lat') - _gf_lon = snapshot.get('lon') + _gf_lat = snapshot.get("lat") + _gf_lon = snapshot.get("lon") if _gf_lat is not None and _gf_lon is not None: try: from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( - update_icao, 'aircraft', _gf_lat, _gf_lon, - {'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')} + update_icao, + "aircraft", + _gf_lat, + _gf_lon, + {"callsign": snapshot.get("callsign"), "altitude": snapshot.get("altitude")}, ): - process_event('adsb', _gf_evt, 'geofence') + process_event("adsb", _gf_evt, "geofence") except Exception: pass @@ -603,7 +680,7 @@ def parse_sbs_stream(service_addr): while adsb_using_service: try: - data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore') + data = sock.recv(SOCKET_BUFFER_SIZE).decode("utf-8", errors="ignore") if not data: flush_pending_updates(force=True) logger.warning("SBS connection closed (no data)") @@ -611,8 +688,8 @@ def parse_sbs_stream(service_addr): adsb_bytes_received += len(data) buffer += data - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: continue @@ -622,8 +699,8 @@ def parse_sbs_stream(service_addr): if adsb_lines_received <= 3: logger.info(f"SBS line {adsb_lines_received}: {line[:100]}") - parts = line.split(',') - if len(parts) < 11 or parts[0] != 'MSG': + parts = line.split(",") + if len(parts) < 11 or parts[0] != "MSG": if adsb_lines_received <= 5: logger.debug(f"Skipping non-MSG line: {line[:50]}") continue @@ -646,90 +723,105 @@ def parse_sbs_stream(service_addr): ) adsb_history_writer.enqueue(history_record) - aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao} + aircraft = app_module.adsb_aircraft.get(icao) or {"icao": icao} # Look up aircraft type from database (once per ICAO) if icao not in _looked_up_icaos: _looked_up_icaos.add(icao) db_info = aircraft_db.lookup(icao) if db_info: - if db_info['registration']: - aircraft['registration'] = db_info['registration'] - if db_info['type_code']: - aircraft['type_code'] = db_info['type_code'] - if db_info['type_desc']: - aircraft['type_desc'] = db_info['type_desc'] + if db_info["registration"]: + aircraft["registration"] = db_info["registration"] + if db_info["type_code"]: + aircraft["type_code"] = db_info["type_code"] + if db_info["type_desc"]: + aircraft["type_desc"] = db_info["type_desc"] - if msg_type == '1' and len(parts) > 10: + if msg_type == "1" and len(parts) > 10: callsign = parts[10].strip() if callsign: - aircraft['callsign'] = callsign + aircraft["callsign"] = callsign - elif msg_type == '3' and len(parts) > 15: + elif msg_type == "3" and len(parts) > 15: if parts[11]: with contextlib.suppress(ValueError, TypeError): - aircraft['altitude'] = int(float(parts[11])) + aircraft["altitude"] = int(float(parts[11])) if parts[14] and parts[15]: try: - aircraft['lat'] = float(parts[14]) - aircraft['lon'] = float(parts[15]) + aircraft["lat"] = float(parts[14]) + aircraft["lon"] = float(parts[15]) except (ValueError, TypeError): pass - elif msg_type == '4' and len(parts) > 16: + elif msg_type == "4" and len(parts) > 16: if parts[12]: with contextlib.suppress(ValueError, TypeError): - aircraft['speed'] = int(float(parts[12])) + aircraft["speed"] = int(float(parts[12])) if parts[13]: with contextlib.suppress(ValueError, TypeError): - aircraft['heading'] = int(float(parts[13])) + aircraft["heading"] = int(float(parts[13])) if parts[16]: try: - aircraft['vertical_rate'] = int(float(parts[16])) - if abs(aircraft['vertical_rate']) > 4000: - process_event('adsb', { - 'type': 'vertical_rate_anomaly', 'icao': icao, - 'callsign': aircraft.get('callsign', ''), - 'vertical_rate': aircraft['vertical_rate'], - }, 'vertical_rate_anomaly') + aircraft["vertical_rate"] = int(float(parts[16])) + if abs(aircraft["vertical_rate"]) > 4000: + process_event( + "adsb", + { + "type": "vertical_rate_anomaly", + "icao": icao, + "callsign": aircraft.get("callsign", ""), + "vertical_rate": aircraft["vertical_rate"], + }, + "vertical_rate_anomaly", + ) except (ValueError, TypeError): pass - elif msg_type == '5' and len(parts) > 11: + elif msg_type == "5" and len(parts) > 11: if parts[10]: callsign = parts[10].strip() if callsign: - aircraft['callsign'] = callsign + aircraft["callsign"] = callsign if parts[11]: with contextlib.suppress(ValueError, TypeError): - aircraft['altitude'] = int(float(parts[11])) + aircraft["altitude"] = int(float(parts[11])) - elif msg_type == '6' and len(parts) > 17: + elif msg_type == "6" and len(parts) > 17: if parts[17]: - aircraft['squawk'] = parts[17] + aircraft["squawk"] = parts[17] sq = parts[17].strip() - _EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'} + _EMERGENCY_SQUAWKS = { + "7700": "General Emergency", + "7600": "Comms Failure", + "7500": "Hijack", + } if sq in _EMERGENCY_SQUAWKS: - process_event('adsb', { - 'type': 'squawk_emergency', 'icao': icao, - 'callsign': aircraft.get('callsign', ''), - 'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq], - }, 'squawk_emergency') + process_event( + "adsb", + { + "type": "squawk_emergency", + "icao": icao, + "callsign": aircraft.get("callsign", ""), + "squawk": sq, + "meaning": _EMERGENCY_SQUAWKS[sq], + }, + "squawk_emergency", + ) - elif msg_type == '2' and len(parts) > 15: + elif msg_type == "2" and len(parts) > 15: if parts[11]: with contextlib.suppress(ValueError, TypeError): - aircraft['altitude'] = int(float(parts[11])) + aircraft["altitude"] = int(float(parts[11])) if parts[12]: with contextlib.suppress(ValueError, TypeError): - aircraft['speed'] = int(float(parts[12])) + aircraft["speed"] = int(float(parts[12])) if parts[13]: with contextlib.suppress(ValueError, TypeError): - aircraft['heading'] = int(float(parts[13])) + aircraft["heading"] = int(float(parts[13])) if parts[14] and parts[15]: try: - aircraft['lat'] = float(parts[14]) - aircraft['lon'] = float(parts[15]) + aircraft["lat"] = float(parts[14]) + aircraft["lon"] = float(parts[15]) except (ValueError, TypeError): pass @@ -760,26 +852,28 @@ def parse_sbs_stream(service_addr): logger.info("SBS stream parser stopped") -@adsb_bp.route('/tools') +@adsb_bp.route("/tools") def check_adsb_tools(): """Check for ADS-B decoding tools and hardware.""" # Check available decoders has_dump1090 = find_dump1090() is not None - has_readsb = shutil.which('readsb') is not None - has_rtl_adsb = shutil.which('rtl_adsb') is not None + has_readsb = shutil.which("readsb") is not None + has_rtl_adsb = shutil.which("rtl_adsb") is not None - return jsonify({ - 'dump1090': has_dump1090, - 'readsb': has_readsb, - 'rtl_adsb': has_rtl_adsb, - 'has_rtlsdr': None, - 'has_soapy_sdr': None, - 'soapy_types': [], - 'needs_readsb': False - }) + return jsonify( + { + "dump1090": has_dump1090, + "readsb": has_readsb, + "rtl_adsb": has_rtl_adsb, + "has_rtlsdr": None, + "has_soapy_sdr": None, + "soapy_types": [], + "needs_readsb": False, + } + ) -@adsb_bp.route('/status') +@adsb_bp.route("/status") def adsb_status(): """Get ADS-B tracking status for debugging.""" # Check if dump1090 process is still running @@ -787,24 +881,26 @@ def adsb_status(): if app_module.adsb_process: dump1090_running = app_module.adsb_process.poll() is None - return jsonify({ - 'tracking_active': adsb_using_service, - 'active_device': adsb_active_device, - 'connected_to_sbs': adsb_connected, - 'messages_received': adsb_messages_received, - 'bytes_received': adsb_bytes_received, - 'lines_received': adsb_lines_received, - 'last_message_time': adsb_last_message_time, - 'aircraft_count': len(app_module.adsb_aircraft), - 'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data - 'queue_size': _adsb_stream_queue_depth(), - 'dump1090_path': find_dump1090(), - 'dump1090_running': dump1090_running, - 'port_30003_open': check_dump1090_service() is not None - }) + return jsonify( + { + "tracking_active": adsb_using_service, + "active_device": adsb_active_device, + "connected_to_sbs": adsb_connected, + "messages_received": adsb_messages_received, + "bytes_received": adsb_bytes_received, + "lines_received": adsb_lines_received, + "last_message_time": adsb_last_message_time, + "aircraft_count": len(app_module.adsb_aircraft), + "aircraft": dict(app_module.adsb_aircraft), # Full aircraft data + "queue_size": _adsb_stream_queue_depth(), + "dump1090_path": find_dump1090(), + "dump1090_running": dump1090_running, + "port_30003_open": check_dump1090_service() is not None, + } + ) -@adsb_bp.route('/aircraft') +@adsb_bp.route("/aircraft") def adsb_aircraft_export(): """Export current ADS-B aircraft data as JSON. @@ -821,43 +917,48 @@ def adsb_aircraft_export(): """ aircraft = dict(app_module.adsb_aircraft) - icao_filter = request.args.get('icao', '').upper() + icao_filter = request.args.get("icao", "").upper() if icao_filter: aircraft = {k: v for k, v in aircraft.items() if k.upper() == icao_filter} - if request.args.get('military') == 'true': + if request.args.get("military") == "true": try: from utils.military_icao import is_military_icao + aircraft = {k: v for k, v in aircraft.items() if is_military_icao(k)} except ImportError: pass - return jsonify({ - 'count': len(aircraft), - 'aircraft': list(aircraft.values()), - 'sbs_port': 30003, # dump1090 SBS stream for tools like Virtual Radar Server - }) + return jsonify( + { + "count": len(aircraft), + "aircraft": list(aircraft.values()), + "sbs_port": 30003, # dump1090 SBS stream for tools like Virtual Radar Server + } + ) -@adsb_bp.route('/session') +@adsb_bp.route("/session") def adsb_session(): """Get ADS-B session status and uptime.""" session = _get_active_session() uptime_seconds = None - if session and session.get('started_at'): - started_at = session['started_at'] + if session and session.get("started_at"): + started_at = session["started_at"] if isinstance(started_at, datetime): uptime_seconds = int((datetime.now(timezone.utc) - started_at).total_seconds()) - return jsonify({ - 'tracking_active': adsb_using_service, - 'connected_to_sbs': adsb_connected, - 'active_device': adsb_active_device, - 'session': session, - 'uptime_seconds': uptime_seconds, - }) + return jsonify( + { + "tracking_active": adsb_using_service, + "connected_to_sbs": adsb_connected, + "active_device": adsb_active_device, + "session": session, + "uptime_seconds": uptime_seconds, + } + ) -@adsb_bp.route('/start', methods=['POST']) +@adsb_bp.route("/start", methods=["POST"]) def start_adsb(): """Start ADS-B tracking.""" global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active @@ -865,26 +966,24 @@ def start_adsb(): with app_module.adsb_lock: if adsb_using_service: session = _get_active_session() - return jsonify({ - 'status': 'already_running', - 'message': 'ADS-B tracking already active', - 'session': session - }), 409 + return jsonify( + {"status": "already_running", "message": "ADS-B tracking already active", "session": session} + ), 409 data = request.get_json(silent=True) or {} - start_source = data.get('source') + start_source = data.get("source") started_by = request.remote_addr # Validate inputs try: - gain = int(validate_gain(data.get('gain', '40'))) - device = validate_device_index(data.get('device', '0')) + gain = int(validate_gain(data.get("gain", "40"))) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) # Check for remote SBS connection (e.g., remote dump1090) - remote_sbs_host = data.get('remote_sbs_host') - remote_sbs_port = data.get('remote_sbs_port', 30003) + remote_sbs_host = data.get("remote_sbs_host") + remote_sbs_port = data.get("remote_sbs_port", 30003) if remote_sbs_host: # Validate and connect to remote dump1090 SBS output @@ -901,17 +1000,15 @@ def start_adsb(): thread.start() session = _record_session_start( device_index=device, - sdr_type='remote', + sdr_type="remote", remote_host=remote_sbs_host, remote_port=remote_sbs_port, start_source=start_source, started_by=started_by, ) - return jsonify({ - 'status': 'started', - 'message': f'Connected to remote dump1090 at {remote_addr}', - 'session': session - }) + return jsonify( + {"status": "started", "message": f"Connected to remote dump1090 at {remote_addr}", "session": session} + ) # Kill any stale app-spawned dump1090 from a previous run before checking the port cleanup_stale_dump1090() @@ -925,20 +1022,16 @@ def start_adsb(): thread.start() session = _record_session_start( device_index=device, - sdr_type='external', - remote_host='localhost', + sdr_type="external", + remote_host="localhost", remote_port=ADSB_SBS_PORT, start_source=start_source, started_by=started_by, ) - return jsonify({ - 'status': 'started', - 'message': 'Connected to existing dump1090 service', - 'session': session - }) + return jsonify({"status": "started", "message": "Connected to existing dump1090 service", "session": session}) # Get SDR type from request - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -949,12 +1042,14 @@ def start_adsb(): if sdr_type == SDRType.RTL_SDR: dump1090_path = find_dump1090() if not dump1090_path: - return api_error('dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/') + return api_error("dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/") else: # For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support) - dump1090_path = shutil.which('readsb') or find_dump1090() + dump1090_path = shutil.which("readsb") or find_dump1090() if not dump1090_path: - return api_error(f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.') + return api_error( + f"readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support." + ) # Kill any stale app-started process (use process group to ensure full cleanup) if app_module.adsb_process: @@ -974,13 +1069,9 @@ def start_adsb(): # Check if device is available before starting local dump1090 device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "adsb", sdr_type_str) if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 + return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409 # Track claimed device immediately so stop_adsb() can always release it adsb_active_device = device @@ -991,13 +1082,9 @@ def start_adsb(): builder = SDRFactory.get_builder(sdr_type) # Build ADS-B decoder command - bias_t = data.get('bias_t', False) + bias_t = data.get("bias_t", False) adsb_bias_t_active = bias_t - cmd = builder.build_adsb_command( - device=sdr_device, - gain=float(gain), - bias_t=bias_t - ) + cmd = builder.build_adsb_command(device=sdr_device, gain=float(gain), bias_t=bias_t) # Ensure we use the resolved binary path for all SDR types cmd[0] = dump1090_path @@ -1008,7 +1095,7 @@ def start_adsb(): cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, - start_new_session=True # Create new process group for clean shutdown + start_new_session=True, # Create new process group for clean shutdown ) write_dump1090_pid(app_module.adsb_process.pid) @@ -1030,57 +1117,61 @@ def start_adsb(): app_module.release_sdr_device(device_int, sdr_type_str) adsb_active_device = None adsb_active_sdr_type = None - stderr_output = '' + stderr_output = "" if app_module.adsb_process.stderr: with contextlib.suppress(Exception): - stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip() + stderr_output = app_module.adsb_process.stderr.read().decode("utf-8", errors="ignore").strip() # Parse stderr to provide specific guidance - error_type = 'START_FAILED' + error_type = "START_FAILED" stderr_lower = stderr_output.lower() sdr_label = sdr_type.value - if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower: - error_msg = 'SDR device is busy. Another process may be using it.' + if ( + "usb_claim_interface" in stderr_lower + or "libusb_error_busy" in stderr_lower + or "device or resource busy" in stderr_lower + ): + error_msg = "SDR device is busy. Another process may be using it." suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.' - error_type = 'DEVICE_BUSY' - elif 'no hackrf boards found' in stderr_lower or 'hackrf_open' in stderr_lower: - error_msg = f'{sdr_label} device not found.' - suggestion = 'Ensure the HackRF is connected. Try removing and reinserting the device.' - error_type = 'DEVICE_NOT_FOUND' - elif 'soapysdr not found' in stderr_lower or 'soapy' in stderr_lower and 'not found' in stderr_lower: - error_msg = f'SoapySDR driver not found for {sdr_label}.' - suggestion = f'Install SoapySDR and the {sdr_label} module (e.g., soapysdr-module-hackrf).' - error_type = 'DRIVER_NOT_FOUND' - elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower: - error_msg = f'{sdr_label} device not found.' - suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.' - error_type = 'DEVICE_NOT_FOUND' - elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower: - error_msg = 'Kernel DVB-T driver is blocking the device.' + error_type = "DEVICE_BUSY" + elif "no hackrf boards found" in stderr_lower or "hackrf_open" in stderr_lower: + error_msg = f"{sdr_label} device not found." + suggestion = "Ensure the HackRF is connected. Try removing and reinserting the device." + error_type = "DEVICE_NOT_FOUND" + elif "soapysdr not found" in stderr_lower or "soapy" in stderr_lower and "not found" in stderr_lower: + error_msg = f"SoapySDR driver not found for {sdr_label}." + suggestion = f"Install SoapySDR and the {sdr_label} module (e.g., soapysdr-module-hackrf)." + error_type = "DRIVER_NOT_FOUND" + elif ( + "no supported devices" in stderr_lower + or "no rtl-sdr" in stderr_lower + or "failed to open" in stderr_lower + ): + error_msg = f"{sdr_label} device not found." + suggestion = "Ensure the device is connected. Try removing and reinserting the SDR." + error_type = "DEVICE_NOT_FOUND" + elif "kernel driver is active" in stderr_lower or "dvb" in stderr_lower: + error_msg = "Kernel DVB-T driver is blocking the device." suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".' - error_type = 'KERNEL_DRIVER' - elif 'permission' in stderr_lower or 'access' in stderr_lower: - error_msg = f'Permission denied accessing {sdr_label} device.' - suggestion = f'Run Intercept with sudo, or add udev rules for {sdr_label} devices.' - error_type = 'PERMISSION_DENIED' + error_type = "KERNEL_DRIVER" + elif "permission" in stderr_lower or "access" in stderr_lower: + error_msg = f"Permission denied accessing {sdr_label} device." + suggestion = f"Run Intercept with sudo, or add udev rules for {sdr_label} devices." + error_type = "PERMISSION_DENIED" elif sdr_type == SDRType.RTL_SDR: - error_msg = 'dump1090 failed to start.' - suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.' + error_msg = "dump1090 failed to start." + suggestion = "Try removing and reinserting the SDR device, or check if another application is using it." else: - error_msg = f'ADS-B decoder failed to start for {sdr_label}.' - suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.' + error_msg = f"ADS-B decoder failed to start for {sdr_label}." + suggestion = "Ensure readsb is installed with SoapySDR support and the device is connected." - full_msg = f'{error_msg} {suggestion}' + full_msg = f"{error_msg} {suggestion}" if stderr_output and len(stderr_output) < 300: - full_msg += f' (Details: {stderr_output})' + full_msg += f" (Details: {stderr_output})" - return jsonify({ - 'status': 'error', - 'error_type': error_type, - 'message': full_msg - }) + return jsonify({"status": "error", "error_type": error_type, "message": full_msg}) # dump1090 is still running but SBS port never came up β€” device may be # held by a stale process from a previous mode. Kill it so the USB @@ -1102,18 +1193,20 @@ def start_adsb(): app_module.release_sdr_device(device_int, sdr_type_str) adsb_active_device = None adsb_active_sdr_type = None - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': ( - 'SDR device did not become ready in time. ' - 'Another mode may still be releasing the device. ' - 'Please wait a moment and try again.' - ), - }) + return jsonify( + { + "status": "error", + "error_type": "DEVICE_BUSY", + "message": ( + "SDR device did not become ready in time. " + "Another mode may still be releasing the device. " + "Please wait a moment and try again." + ), + } + ) adsb_using_service = True - thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) + thread = threading.Thread(target=parse_sbs_stream, args=(f"localhost:{ADSB_SBS_PORT}",), daemon=True) thread.start() session = _record_session_start( @@ -1124,12 +1217,7 @@ def start_adsb(): start_source=start_source, started_by=started_by, ) - return jsonify({ - 'status': 'started', - 'message': 'ADS-B tracking started', - 'device': device, - 'session': session - }) + return jsonify({"status": "started", "message": "ADS-B tracking started", "device": device, "session": session}) except Exception as e: # Release device on failure app_module.release_sdr_device(device_int, sdr_type_str) @@ -1138,12 +1226,12 @@ def start_adsb(): return api_error(str(e)) -@adsb_bp.route('/stop', methods=['POST']) +@adsb_bp.route("/stop", methods=["POST"]) def stop_adsb(): """Stop ADS-B tracking.""" global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active data = request.get_json(silent=True) or {} - stop_source = data.get('source') + stop_source = data.get("source") stopped_by = request.remote_addr with app_module.adsb_lock: @@ -1166,14 +1254,15 @@ def stop_adsb(): # Turn off bias-T if it was enabled at start β€” the hardware register # persists after the device is closed, so we must explicitly disable it. - if adsb_bias_t_active and (adsb_active_sdr_type or 'rtlsdr') == 'rtlsdr': + if adsb_bias_t_active and (adsb_active_sdr_type or "rtlsdr") == "rtlsdr": from utils.sdr.rtlsdr import disable_bias_t_via_rtl_biast + disable_bias_t_via_rtl_biast(adsb_active_device or 0) adsb_bias_t_active = False # Release device from registry if adsb_active_device is not None: - app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or "rtlsdr") adsb_using_service = False adsb_active_device = None @@ -1182,10 +1271,10 @@ def stop_adsb(): app_module.adsb_aircraft.clear() _looked_up_icaos.clear() session = _record_session_stop(stop_source=stop_source, stopped_by=stopped_by) - return jsonify({'status': 'stopped', 'session': session}) + return jsonify({"status": "stopped", "session": session}) -@adsb_bp.route('/stream') +@adsb_bp.route("/stream") def stream_adsb(): """SSE stream for ADS-B aircraft.""" client_queue: queue.Queue = queue.Queue(maxsize=_ADSB_STREAM_CLIENT_QUEUE_SIZE) @@ -1196,7 +1285,7 @@ def stream_adsb(): # next positional update before rendering. for snapshot in list(app_module.adsb_aircraft.values()): try: - client_queue.put_nowait({'type': 'aircraft', **snapshot}) + client_queue.put_nowait({"type": "aircraft", **snapshot}) except queue.Full: break @@ -1204,7 +1293,7 @@ def stream_adsb(): last_keepalive = time.time() # Send immediate keepalive so Werkzeug dev server flushes response # headers right away (it buffers until first body byte is written). - yield format_sse({'type': 'keepalive'}) + yield format_sse({"type": "keepalive"}) try: while True: @@ -1212,29 +1301,29 @@ def stream_adsb(): msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT) last_keepalive = time.time() with contextlib.suppress(Exception): - process_event('adsb', msg, msg.get('type')) + process_event("adsb", msg, msg.get("type")) yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: - yield format_sse({'type': 'keepalive'}) + yield format_sse({"type": "keepalive"}) last_keepalive = now finally: with _adsb_stream_subscribers_lock: _adsb_stream_subscribers.discard(client_queue) - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response = Response(generate(), mimetype="text/event-stream") + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response -@adsb_bp.route('/dashboard') +@adsb_bp.route("/dashboard") def adsb_dashboard(): """Popout ADS-B dashboard.""" - embedded = request.args.get('embedded', 'false') == 'true' + embedded = request.args.get("embedded", "false") == "true" return render_template( - 'adsb_dashboard.html', + "adsb_dashboard.html", shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, adsb_auto_start=ADSB_AUTO_START, default_latitude=DEFAULT_LATITUDE, @@ -1243,24 +1332,24 @@ def adsb_dashboard(): ) -@adsb_bp.route('/history') +@adsb_bp.route("/history") def adsb_history(): """ADS-B history reporting dashboard.""" history_available = ADSB_HISTORY_ENABLED and PSYCOPG2_AVAILABLE - resp = make_response(render_template('adsb_history.html', history_enabled=history_available)) - resp.headers['Cache-Control'] = 'no-store' + resp = make_response(render_template("adsb_history.html", history_enabled=history_available)) + resp.headers["Cache-Control"] = "no-store" return resp -@adsb_bp.route('/history/summary') +@adsb_bp.route("/history/summary") def adsb_history_summary(): """Summary stats for ADS-B history window.""" if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: - return api_error('ADS-B history is disabled', 503) + return api_error("ADS-B history is disabled", 503) _ensure_history_schema() - since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080) - window = f'{since_minutes} minutes' + since_minutes = _parse_int_param(request.args.get("since_minutes"), 1440, 1, 10080) + window = f"{since_minutes} minutes" sql = """ SELECT @@ -1278,21 +1367,21 @@ def adsb_history_summary(): return jsonify(row) except Exception as exc: logger.warning("ADS-B history summary failed: %s", exc) - return api_error('History database unavailable', 503) + return api_error("History database unavailable", 503) -@adsb_bp.route('/history/aircraft') +@adsb_bp.route("/history/aircraft") def adsb_history_aircraft(): """List latest aircraft snapshots for a time window.""" if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: - return api_error('ADS-B history is disabled', 503) + return api_error("ADS-B history is disabled", 503) _ensure_history_schema() - since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080) - limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000) - search = (request.args.get('search') or '').strip() - window = f'{since_minutes} minutes' - pattern = f'%{search}%' + since_minutes = _parse_int_param(request.args.get("since_minutes"), 1440, 1, 10080) + limit = _parse_int_param(request.args.get("limit"), 200, 1, 2000) + search = (request.args.get("search") or "").strip() + window = f"{since_minutes} minutes" + pattern = f"%{search}%" sql = """ SELECT * @@ -1324,26 +1413,26 @@ def adsb_history_aircraft(): with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(sql, (window, search, pattern, pattern, pattern, limit)) rows = cur.fetchall() - return jsonify({'aircraft': rows, 'count': len(rows)}) + return jsonify({"aircraft": rows, "count": len(rows)}) except Exception as exc: logger.warning("ADS-B history aircraft query failed: %s", exc) - return api_error('History database unavailable', 503) + return api_error("History database unavailable", 503) -@adsb_bp.route('/history/timeline') +@adsb_bp.route("/history/timeline") def adsb_history_timeline(): """Timeline snapshots for a specific aircraft.""" if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: - return api_error('ADS-B history is disabled', 503) + return api_error("ADS-B history is disabled", 503) _ensure_history_schema() - icao = (request.args.get('icao') or '').strip().upper() + icao = (request.args.get("icao") or "").strip().upper() if not icao: - return api_error('icao is required', 400) + return api_error("icao is required", 400) - since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080) - limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000) - window = f'{since_minutes} minutes' + since_minutes = _parse_int_param(request.args.get("since_minutes"), 1440, 1, 10080) + limit = _parse_int_param(request.args.get("limit"), 2000, 1, 20000) + window = f"{since_minutes} minutes" sql = """ SELECT captured_at, altitude, speed, heading, vertical_rate, lat, lon, squawk @@ -1358,23 +1447,23 @@ def adsb_history_timeline(): with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(sql, (icao, window, limit)) rows = cur.fetchall() - return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)}) + return jsonify({"icao": icao, "timeline": rows, "count": len(rows)}) except Exception as exc: logger.warning("ADS-B history timeline query failed: %s", exc) - return api_error('History database unavailable', 503) + return api_error("History database unavailable", 503) -@adsb_bp.route('/history/messages') +@adsb_bp.route("/history/messages") def adsb_history_messages(): """Raw message history for a specific aircraft.""" if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: - return api_error('ADS-B history is disabled', 503) + return api_error("ADS-B history is disabled", 503) _ensure_history_schema() - icao = (request.args.get('icao') or '').strip().upper() - since_minutes = _parse_int_param(request.args.get('since_minutes'), 30, 1, 10080) - limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000) - window = f'{since_minutes} minutes' + icao = (request.args.get("icao") or "").strip().upper() + since_minutes = _parse_int_param(request.args.get("since_minutes"), 30, 1, 10080) + limit = _parse_int_param(request.args.get("limit"), 200, 1, 2000) + window = f"{since_minutes} minutes" sql = """ SELECT received_at, msg_type, callsign, altitude, speed, heading, vertical_rate, lat, lon, squawk @@ -1389,33 +1478,33 @@ def adsb_history_messages(): with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(sql, (window, icao, icao, limit)) rows = cur.fetchall() - return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)}) + return jsonify({"icao": icao, "messages": rows, "count": len(rows)}) except Exception as exc: logger.warning("ADS-B history message query failed: %s", exc) - return api_error('History database unavailable', 503) + return api_error("History database unavailable", 503) -@adsb_bp.route('/history/export') +@adsb_bp.route("/history/export") def adsb_history_export(): """Export ADS-B history data in CSV or JSON format.""" if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: - return api_error('ADS-B history is disabled', 503) + return api_error("ADS-B history is disabled", 503) _ensure_history_schema() - export_format = str(request.args.get('format') or 'csv').strip().lower() - export_type = str(request.args.get('type') or 'all').strip().lower() - if export_format not in {'csv', 'json'}: - return api_error('format must be csv or json', 400) - if export_type not in {'messages', 'snapshots', 'sessions', 'all'}: - return api_error('type must be messages, snapshots, sessions, or all', 400) + export_format = str(request.args.get("format") or "csv").strip().lower() + export_type = str(request.args.get("type") or "all").strip().lower() + if export_format not in {"csv", "json"}: + return api_error("format must be csv or json", 400) + if export_type not in {"messages", "snapshots", "sessions", "all"}: + return api_error("type must be messages, snapshots, sessions, or all", 400) scope, since_minutes, start, end = _parse_export_scope(request.args) - icao = (request.args.get('icao') or '').strip().upper() - search = (request.args.get('search') or '').strip() - classification = str(request.args.get('classification') or 'all').strip().lower() - if classification not in {'all', 'military', 'civilian'}: - classification = 'all' - pattern = f'%{search}%' + icao = (request.args.get("icao") or "").strip().upper() + search = (request.args.get("search") or "").strip() + classification = str(request.args.get("classification") or "all").strip().lower() + if classification not in {"all", "military", "civilian"}: + classification = "all" + pattern = f"%{search}%" snapshots: list[dict[str, Any]] = [] messages: list[dict[str, Any]] = [] @@ -1423,27 +1512,24 @@ def adsb_history_export(): def _filter_by_classification( rows: list[dict[str, Any]], - icao_key: str = 'icao', - callsign_key: str = 'callsign', + icao_key: str = "icao", + callsign_key: str = "callsign", ) -> list[dict[str, Any]]: - if classification == 'all': + if classification == "all": return rows - want_military = classification == 'military' - return [ - r for r in rows - if _is_military_aircraft(r.get(icao_key, ''), r.get(callsign_key)) == want_military - ] + want_military = classification == "military" + return [r for r in rows if _is_military_aircraft(r.get(icao_key, ""), r.get(callsign_key)) == want_military] try: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur: - if export_type in {'snapshots', 'all'}: + if export_type in {"snapshots", "all"}: snapshot_where: list[str] = [] snapshot_params: list[Any] = [] _add_time_filter( where_parts=snapshot_where, params=snapshot_params, scope=scope, - timestamp_field='captured_at', + timestamp_field="captured_at", since_minutes=since_minutes, start=start, end=end, @@ -1466,14 +1552,14 @@ def adsb_history_export(): cur.execute(snapshot_sql, tuple(snapshot_params)) snapshots = _filter_by_classification(cur.fetchall()) - if export_type in {'messages', 'all'}: + if export_type in {"messages", "all"}: message_where: list[str] = [] message_params: list[Any] = [] _add_time_filter( where_parts=message_where, params=message_params, scope=scope, - timestamp_field='received_at', + timestamp_field="received_at", since_minutes=since_minutes, start=start, end=end, @@ -1497,15 +1583,15 @@ def adsb_history_export(): cur.execute(message_sql, tuple(message_params)) messages = _filter_by_classification(cur.fetchall()) - if export_type in {'sessions', 'all'}: + if export_type in {"sessions", "all"}: session_where: list[str] = [] session_params: list[Any] = [] - if scope == 'custom' and start is not None and end is not None: + if scope == "custom" and start is not None and end is not None: session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s") session_params.extend([end, start, end]) - elif scope == 'window': + elif scope == "window": session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s") - session_params.append(f'{since_minutes} minutes') + session_params.append(f"{since_minutes} minutes") session_sql = """ SELECT id, started_at, ended_at, device_index, sdr_type, remote_host, @@ -1519,47 +1605,47 @@ def adsb_history_export(): sessions = cur.fetchall() except Exception as exc: logger.warning("ADS-B history export failed: %s", exc) - return api_error('History database unavailable', 503) + return api_error("History database unavailable", 503) exported_at = datetime.now(timezone.utc).isoformat() - timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') - filename_scope = 'all' if scope == 'all' else ('custom' if scope == 'custom' else f'{since_minutes}m') - filename = f'adsb_history_{export_type}_{filename_scope}_{timestamp}.{export_format}' + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + filename_scope = "all" if scope == "all" else ("custom" if scope == "custom" else f"{since_minutes}m") + filename = f"adsb_history_{export_type}_{filename_scope}_{timestamp}.{export_format}" - if export_format == 'json': + if export_format == "json": payload = { - 'exported_at': exported_at, - 'format': export_format, - 'type': export_type, - 'scope': scope, - 'since_minutes': None if scope != 'window' else since_minutes, - 'filters': { - 'icao': icao or None, - 'search': search or None, - 'classification': classification, - 'start': start.isoformat() if start else None, - 'end': end.isoformat() if end else None, + "exported_at": exported_at, + "format": export_format, + "type": export_type, + "scope": scope, + "since_minutes": None if scope != "window" else since_minutes, + "filters": { + "icao": icao or None, + "search": search or None, + "classification": classification, + "start": start.isoformat() if start else None, + "end": end.isoformat() if end else None, }, - 'counts': { - 'messages': len(messages), - 'snapshots': len(snapshots), - 'sessions': len(sessions), + "counts": { + "messages": len(messages), + "snapshots": len(snapshots), + "sessions": len(sessions), }, - 'messages': _rows_to_serializable(messages), - 'snapshots': _rows_to_serializable(snapshots), - 'sessions': _rows_to_serializable(sessions), + "messages": _rows_to_serializable(messages), + "snapshots": _rows_to_serializable(snapshots), + "sessions": _rows_to_serializable(sessions), } response = Response( json.dumps(payload, indent=2, default=str), - mimetype='application/json', + mimetype="application/json", ) - response.headers['Content-Disposition'] = f'attachment; filename={filename}' + response.headers["Content-Disposition"] = f"attachment; filename={filename}" return response csv_data = _build_export_csv( exported_at=exported_at, scope=scope, - since_minutes=since_minutes if scope == 'window' else None, + since_minutes=since_minutes if scope == "window" else None, icao=icao, search=search, classification=classification, @@ -1568,47 +1654,49 @@ def adsb_history_export(): sessions=sessions, export_type=export_type, ) - response = Response(csv_data, mimetype='text/csv') - response.headers['Content-Disposition'] = f'attachment; filename={filename}' + response = Response(csv_data, mimetype="text/csv") + response.headers["Content-Disposition"] = f"attachment; filename={filename}" return response -@adsb_bp.route('/history/prune', methods=['POST']) +@adsb_bp.route("/history/prune", methods=["POST"]) def adsb_history_prune(): """Delete ADS-B history for a selected time range or entire dataset.""" if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: - return api_error('ADS-B history is disabled', 503) + return api_error("ADS-B history is disabled", 503) _ensure_history_schema() payload = request.get_json(silent=True) or {} - mode = str(payload.get('mode') or 'range').strip().lower() - if mode not in {'range', 'all'}: - return api_error('mode must be range or all', 400) + mode = str(payload.get("mode") or "range").strip().lower() + if mode not in {"range", "all"}: + return api_error("mode must be range or all", 400) try: with _get_history_connection() as conn, conn.cursor() as cur: - deleted = {'messages': 0, 'snapshots': 0} + deleted = {"messages": 0, "snapshots": 0} - if mode == 'all': + if mode == "all": cur.execute("DELETE FROM adsb_messages") - deleted['messages'] = max(0, cur.rowcount or 0) + deleted["messages"] = max(0, cur.rowcount or 0) cur.execute("DELETE FROM adsb_snapshots") - deleted['snapshots'] = max(0, cur.rowcount or 0) - return jsonify({ - 'status': 'ok', - 'mode': 'all', - 'deleted': deleted, - 'total_deleted': deleted['messages'] + deleted['snapshots'], - }) + deleted["snapshots"] = max(0, cur.rowcount or 0) + return jsonify( + { + "status": "ok", + "mode": "all", + "deleted": deleted, + "total_deleted": deleted["messages"] + deleted["snapshots"], + } + ) - start = _parse_iso_datetime(payload.get('start')) - end = _parse_iso_datetime(payload.get('end')) + start = _parse_iso_datetime(payload.get("start")) + end = _parse_iso_datetime(payload.get("end")) if start is None or end is None: - return api_error('start and end ISO datetime values are required', 400) + return api_error("start and end ISO datetime values are required", 400) if end <= start: - return api_error('end must be after start', 400) + return api_error("end must be after start", 400) if end - start > timedelta(days=31): - return api_error('range cannot exceed 31 days', 400) + return api_error("range cannot exceed 31 days", 400) cur.execute( """ @@ -1618,7 +1706,7 @@ def adsb_history_prune(): """, (start, end), ) - deleted['messages'] = max(0, cur.rowcount or 0) + deleted["messages"] = max(0, cur.rowcount or 0) cur.execute( """ @@ -1628,101 +1716,104 @@ def adsb_history_prune(): """, (start, end), ) - deleted['snapshots'] = max(0, cur.rowcount or 0) + deleted["snapshots"] = max(0, cur.rowcount or 0) - return jsonify({ - 'status': 'ok', - 'mode': 'range', - 'start': start.isoformat(), - 'end': end.isoformat(), - 'deleted': deleted, - 'total_deleted': deleted['messages'] + deleted['snapshots'], - }) + return jsonify( + { + "status": "ok", + "mode": "range", + "start": start.isoformat(), + "end": end.isoformat(), + "deleted": deleted, + "total_deleted": deleted["messages"] + deleted["snapshots"], + } + ) except Exception as exc: logger.warning("ADS-B history prune failed: %s", exc) - return api_error('History database unavailable', 503) + return api_error("History database unavailable", 503) # ============================================ # AIRCRAFT DATABASE MANAGEMENT # ============================================ -@adsb_bp.route('/aircraft-db/status') + +@adsb_bp.route("/aircraft-db/status") def aircraft_db_status(): """Get aircraft database status.""" return jsonify(aircraft_db.get_db_status()) -@adsb_bp.route('/aircraft-db/check-updates') +@adsb_bp.route("/aircraft-db/check-updates") def aircraft_db_check_updates(): """Check for aircraft database updates.""" result = aircraft_db.check_for_updates() return jsonify(result) -@adsb_bp.route('/aircraft-db/download', methods=['POST']) +@adsb_bp.route("/aircraft-db/download", methods=["POST"]) def aircraft_db_download(): """Download/update aircraft database.""" global _looked_up_icaos result = aircraft_db.download_database() - if result.get('success'): + if result.get("success"): # Clear lookup cache so new data is used _looked_up_icaos.clear() return jsonify(result) -@adsb_bp.route('/aircraft-db/delete', methods=['POST']) +@adsb_bp.route("/aircraft-db/delete", methods=["POST"]) def aircraft_db_delete(): """Delete aircraft database.""" result = aircraft_db.delete_database() return jsonify(result) -@adsb_bp.route('/aircraft-photo/') +@adsb_bp.route("/aircraft-photo/") def aircraft_photo(registration: str): """Fetch aircraft photo from Planespotters.net API.""" import requests # Validate registration format (alphanumeric with dashes) - if not registration or not all(c.isalnum() or c == '-' for c in registration): - return api_error('Invalid registration', 400) + if not registration or not all(c.isalnum() or c == "-" for c in registration): + return api_error("Invalid registration", 400) try: # Planespotters.net public API - url = f'https://api.planespotters.net/pub/photos/reg/{registration}' - resp = requests.get(url, timeout=5, headers={ - 'User-Agent': 'INTERCEPT-ADS-B/1.0' - }) + url = f"https://api.planespotters.net/pub/photos/reg/{registration}" + resp = requests.get(url, timeout=5, headers={"User-Agent": "INTERCEPT-ADS-B/1.0"}) if resp.status_code == 200: data = resp.json() - if data.get('photos') and len(data['photos']) > 0: - photo = data['photos'][0] - return jsonify({ - 'success': True, - 'thumbnail': photo.get('thumbnail_large', {}).get('src'), - 'link': photo.get('link'), - 'photographer': photo.get('photographer') - }) + if data.get("photos") and len(data["photos"]) > 0: + photo = data["photos"][0] + return jsonify( + { + "success": True, + "thumbnail": (photo.get("thumbnail_large") or photo.get("thumbnail") or {}).get("src"), + "link": photo.get("link"), + "photographer": photo.get("photographer"), + } + ) - return jsonify({'success': False, 'error': 'No photo found'}) + return jsonify({"success": False, "error": "No photo found"}) except requests.Timeout: - return jsonify({'success': False, 'error': 'Request timeout'}), 504 + return jsonify({"success": False, "error": "Request timeout"}), 504 except Exception as e: logger.debug(f"Error fetching aircraft photo: {e}") - return jsonify({'success': False, 'error': str(e)}), 500 + return jsonify({"success": False, "error": str(e)}), 500 -@adsb_bp.route('/aircraft//messages') +@adsb_bp.route("/aircraft//messages") def get_aircraft_messages(icao: str): """Get correlated ACARS/VDL2 messages for an aircraft.""" - if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao): - return api_error('Invalid ICAO', 400) + if not icao or not all(c in "0123456789ABCDEFabcdef" for c in icao): + return api_error("Invalid ICAO", 400) aircraft = app_module.adsb_aircraft.get(icao.upper()) - callsign = aircraft.get('callsign') if aircraft else None - registration = aircraft.get('registration') if aircraft else None + callsign = aircraft.get("callsign") if aircraft else None + registration = aircraft.get("registration") if aircraft else None messages = get_flight_correlator().get_messages_for_aircraft( icao=icao.upper(), callsign=callsign, registration=registration @@ -1730,13 +1821,13 @@ def get_aircraft_messages(icao: str): # Backfill translation on messages missing label_description try: - for msg in messages.get('acars', []): - if not msg.get('label_description'): + for msg in messages.get("acars", []): + if not msg.get("label_description"): translation = translate_message(msg) - msg['label_description'] = translation['label_description'] - msg['message_type'] = translation['message_type'] - msg['parsed'] = translation['parsed'] + msg["label_description"] = translation["label_description"] + msg["message_type"] = translation["message_type"] + msg["parsed"] = translation["parsed"] except Exception: pass - return api_success(data={'icao': icao.upper(), **messages}) + return api_success(data={"icao": icao.upper(), **messages}) diff --git a/static/js/core/cheat-sheets.js b/static/js/core/cheat-sheets.js index a3d1806..3047d5c 100644 --- a/static/js/core/cheat-sheets.js +++ b/static/js/core/cheat-sheets.js @@ -17,12 +17,13 @@ const CheatSheets = (function () { sstv: { title: 'ISS SSTV', icon: 'πŸ–ΌοΈ', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead β€” check pass times'] }, weathersat: { title: 'Weather Satellites', icon: '🌀️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] }, sstv_general:{ title: 'HF SSTV', icon: 'πŸ“·', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] }, - gps: { title: 'GPS Receiver', icon: 'πŸ—ΊοΈ', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] }, - spaceweather:{ title: 'Space Weather', icon: 'β˜€οΈ', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (β‰₯5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] }, - controller_monitor: { title: 'Controller Monitor', icon: 'πŸ–§', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] }, - tscm: { title: 'TSCM Counter-Surveillance', icon: 'πŸ”', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures β€” detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, + gps: { title: 'GPS Receiver', icon: 'πŸ—ΊοΈ', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] }, + spaceweather:{ title: 'Space Weather', icon: 'β˜€οΈ', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (β‰₯5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] }, + controller_monitor: { title: 'Controller Monitor', icon: 'πŸ–§', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] }, + tscm: { title: 'TSCM Counter-Surveillance', icon: 'πŸ”', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures β€” detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, spystations: { title: 'Spy Stations', icon: 'πŸ•΅οΈ', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] }, websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] }, + drone: { title: 'Drone Intelligence', icon: '🚁', hardware: 'WiFi adapter (monitor mode) + RTL-SDR + optional HackRF', description: 'Multi-vector UAV detection: ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, HackRF 2.4/5.8 GHz.', whatToExpect: 'Drone contacts with ID, operator, GPS position (if broadcast), detection vectors, and risk level.', tips: ['Remote ID is mandatory in the US/EU since 2023 β€” absence flags high risk', 'RTL-SDR catches DJI/FPV video links on 2.4 GHz if HackRF unavailable', 'Risk HIGH = no Remote ID or non-compliant; MEDIUM = multi-vector or RSSI anomaly', 'Map markers appear only for contacts with GPS coordinates from Remote ID'] }, subghz: { title: 'SubGHz Transceiver', icon: 'πŸ“‘', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] }, rtlamr: { title: 'Utility Meter Reader', icon: '⚑', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] }, waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] }, diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index e70f3c2..34c20ca 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -3555,17 +3555,15 @@ sudo make install const photoCache = {}; async function fetchAircraftPhoto(registration) { - const container = document.getElementById('aircraftPhotoContainer'); - const img = document.getElementById('aircraftPhoto'); - const link = document.getElementById('aircraftPhotoLink'); - const credit = document.getElementById('aircraftPhotoCredit'); - - if (!container || !img) return; - - // Check cache first + // Check cache first (synchronous path β€” DOM refs are always current here) if (photoCache[registration]) { const cached = photoCache[registration]; if (cached.thumbnail) { + const container = document.getElementById('aircraftPhotoContainer'); + const img = document.getElementById('aircraftPhoto'); + const link = document.getElementById('aircraftPhotoLink'); + const credit = document.getElementById('aircraftPhotoCredit'); + if (!container || !img) return; img.src = cached.thumbnail; link.href = cached.link || '#'; credit.textContent = cached.photographer ? `Photo: ${cached.photographer}` : ''; @@ -3574,13 +3572,24 @@ sudo make install return; } + // Guard: bail early if the panel doesn't exist yet + if (!document.getElementById('aircraftPhotoContainer')) return; + try { const response = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`); const data = await response.json(); - // Cache the result + // Cache before touching DOM β€” subsequent synchronous calls will hit this photoCache[registration] = data; + // Re-query after the await: showAircraftDetails rebuilds innerHTML on every + // RAF update, so refs captured before the await may point to detached nodes. + const container = document.getElementById('aircraftPhotoContainer'); + const img = document.getElementById('aircraftPhoto'); + const link = document.getElementById('aircraftPhotoLink'); + const credit = document.getElementById('aircraftPhotoCredit'); + if (!container || !img) return; + if (data.success && data.thumbnail) { img.src = data.thumbnail; link.href = data.link || '#'; @@ -3591,7 +3600,8 @@ sudo make install } } catch (err) { console.debug('Failed to fetch aircraft photo:', err); - container.style.display = 'none'; + const container = document.getElementById('aircraftPhotoContainer'); + if (container) container.style.display = 'none'; } } diff --git a/templates/partials/help-modal.html b/templates/partials/help-modal.html index 2db94d7..a4cad00 100644 --- a/templates/partials/help-modal.html +++ b/templates/partials/help-modal.html @@ -270,6 +270,17 @@
  • Note: This feature is in early development
  • +

    Drone Intelligence Mode

    +
      +
    • Detects UAVs via three simultaneous vectors: Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz
    • +
    • Parses ASTM F3411 Remote ID broadcast frames β€” captures drone ID, operator ID, and GPS position
    • +
    • RF fingerprinting on 433/868 MHz ISM bands and 2.4/5.8 GHz to detect drone control links and video downlinks
    • +
    • Correlates observations across all vectors into unified DroneContact entries with risk scoring
    • +
    • Risk levels: High (non-compliant / no Remote ID), Medium (multi-vector or RSSI delta >15 dB), Low (compliant, single vector)
    • +
    • Live map shows last known position for Remote ID contacts with GPS data
    • +
    • Requires: WiFi adapter (monitor mode) for BLE Remote ID, RTL-SDR for 433/868 MHz, HackRF for 2.4/5.8 GHz
    • +
    +

    Network Monitor

    • Aggregates data from multiple remote INTERCEPT agents
    • diff --git a/templates/partials/nav.html b/templates/partials/nav.html index bf8ef19..b262012 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -150,6 +150,7 @@ {{ mode_item('tscm', 'TSCM', '') }} {{ mode_item('spystations', 'Spy Stations', '') }} {{ mode_item('websdr', 'WebSDR', '') }} + {{ mode_item('drone', 'Drone Intel', '') }}