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

+ +
+

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: