From 99d52eafe7424f6becfbec742e8fcee0749a6402 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 16 Feb 2026 15:12:10 +0000 Subject: [PATCH] chore: Bump version to v2.18.0 Bluetooth enhancements (service data inspector, appearance codes, MAC cluster tracking, behavioral flags, IRK badges, distance estimation), ACARS SoapySDR multi-backend support, dump1090 stale process cleanup, GPS error state, and proximity radar/signal card UI improvements. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 1 + app.py | 57 ++++---- config.py | 14 +- pyproject.toml | 2 +- routes/acars.py | 51 +++++-- routes/adsb.py | 7 + routes/bluetooth_v2.py | 2 +- routes/gps.py | 51 +++++-- routes/sensor.py | 8 +- setup.sh | 39 ++++-- static/css/index.css | 135 ++++++++++++++++-- static/css/modes/gps.css | 5 + static/js/components/proximity-radar.js | 169 ++++++++++++++++++---- static/js/components/signal-cards.js | 8 +- static/js/modes/bluetooth.js | 177 ++++++++++++++++++++++- static/js/modes/bt_locate.js | 23 ++- static/js/modes/gps.js | 81 ++++++----- templates/adsb_dashboard.html | 8 +- templates/index.html | 58 +++++++- templates/partials/modes/bluetooth.html | 1 + utils/bluetooth/aggregator.py | 6 + utils/bluetooth/constants.py | 57 ++++++++ utils/bluetooth/device_key.py | 9 +- utils/bluetooth/fallback_scanner.py | 9 +- utils/bluetooth/models.py | 58 ++++++-- utils/bluetooth/scanner.py | 77 +++++++++- utils/gps.py | 178 ++++++++++++++++++++++++ utils/process.py | 90 ++++++++++++ 28 files changed, 1212 insertions(+), 169 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7068f91..0f9de9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ bluez \ bluetooth \ # GPS support + gpsd \ gpsd-clients \ # Utilities # APRS diff --git a/app.py b/app.py index 7b481f1..b412458 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ from flask import Flask, render_template, jsonify, send_file, Response, request, from werkzeug.security import check_password_hash from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES -from utils.process import cleanup_stale_processes +from utils.process import cleanup_stale_processes, cleanup_stale_dump1090 from utils.sdr import SDRFactory from utils.cleanup import DataStore, cleanup_manager from utils.constants import ( @@ -647,27 +647,27 @@ def export_bluetooth() -> Response: }) -def _get_subghz_active() -> bool: - """Check if SubGHz manager has an active process.""" - try: - from utils.subghz import get_subghz_manager - return get_subghz_manager().active_mode != 'idle' - except Exception: - return False - - -def _get_dmr_active() -> bool: - """Check if Digital Voice decoder has an active process.""" - try: - from routes import dmr as dmr_module - proc = dmr_module.dmr_dsd_process - return bool(dmr_module.dmr_running and proc and proc.poll() is None) - except Exception: - return False - - -@app.route('/health') -def health_check() -> Response: +def _get_subghz_active() -> bool: + """Check if SubGHz manager has an active process.""" + try: + from utils.subghz import get_subghz_manager + return get_subghz_manager().active_mode != 'idle' + except Exception: + return False + + +def _get_dmr_active() -> bool: + """Check if Digital Voice decoder has an active process.""" + try: + from routes import dmr as dmr_module + proc = dmr_module.dmr_dsd_process + return bool(dmr_module.dmr_running and proc and proc.poll() is None) + except Exception: + return False + + +@app.route('/health') +def health_check() -> Response: """Health check endpoint for monitoring.""" import time return jsonify({ @@ -681,12 +681,12 @@ def health_check() -> Response: 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), - 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), - 'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), - 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), - 'dmr': _get_dmr_active(), - 'subghz': _get_subghz_active(), - }, + 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), + 'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), + 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'dmr': _get_dmr_active(), + 'subghz': _get_subghz_active(), + }, 'data': { 'aircraft_count': len(adsb_aircraft), 'vessel_count': len(ais_vessels), @@ -877,6 +877,7 @@ def main() -> None: # Clean up any stale processes from previous runs cleanup_stale_processes() + cleanup_stale_dump1090() # Initialize database for settings storage from utils.database import init_db diff --git a/config.py b/config.py index a13a1ea..e84a439 100644 --- a/config.py +++ b/config.py @@ -7,10 +7,22 @@ import os import sys # Application version -VERSION = "2.17.0" +VERSION = "2.18.0" # Changelog - latest release notes (shown on welcome screen) CHANGELOG = [ + { + "version": "2.18.0", + "date": "February 2026", + "highlights": [ + "Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags", + "Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics", + "ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends", + "ADS-B: stale dump1090 process cleanup via PID file tracking", + "GPS: error state indicator and UI refinements", + "Proximity radar and signal card UI improvements", + ] + }, { "version": "2.17.0", "date": "February 2026", diff --git a/pyproject.toml b/pyproject.toml index d7d6d81..2011bb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "intercept" -version = "2.17.0" +version = "2.18.0" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" readme = "README.md" requires-python = ">=3.9" diff --git a/routes/acars.py b/routes/acars.py index 47f572a..72f8b7d 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -20,8 +20,9 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm -from utils.sse import format_sse -from utils.event_pipeline import process_event +from utils.sdr import SDRFactory, SDRType +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, SSE_KEEPALIVE_INTERVAL, @@ -250,12 +251,22 @@ def start_acars() -> Response: acars_message_count = 0 acars_last_message_time = None + # Resolve SDR type for device selection + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + is_soapy = sdr_type not in (SDRType.RTL_SDR,) + # Build acarsdec command # Different forks have different syntax: # - TLeconte v4+: acarsdec -j -g -p -r ... # - TLeconte v3: acarsdec -o 4 -g -p -r ... # - f00b4r0 (DragonOS): acarsdec --output json:file:- -g -p -r ... - # Note: gain/ppm must come BEFORE -r + # SoapySDR devices: TLeconte uses -d , f00b4r0 uses --soapysdr + # Note: gain/ppm must come BEFORE -r/-d json_flag = get_acarsdec_json_flag(acarsdec_path) cmd = [acarsdec_path] if json_flag == '--output': @@ -266,21 +277,33 @@ def start_acars() -> Response: else: cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x) - # Add gain if not auto (must be before -r) + # Add gain if not auto (must be before -r/-d) if gain and str(gain) != '0': cmd.extend(['-g', str(gain)]) - # Add PPM correction if specified (must be before -r) + # Add PPM correction if specified (must be before -r/-d) if ppm and str(ppm) != '0': cmd.extend(['-p', str(ppm)]) # Add device and frequencies - # f00b4r0 uses --rtlsdr , TLeconte uses -r - if json_flag == '--output': + if is_soapy: + # SoapySDR device (SDRplay, LimeSDR, Airspy, etc.) + sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int) + # Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...") + builder = SDRFactory.get_builder(sdr_type) + device_str = builder._build_device_string(sdr_device) + if json_flag == '--output': + cmd.extend(['-m', '256']) + cmd.extend(['--soapysdr', device_str]) + else: + cmd.extend(['-d', device_str]) + elif json_flag == '--output': + # f00b4r0 fork RTL-SDR: --rtlsdr # Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span) cmd.extend(['-m', '256']) cmd.extend(['--rtlsdr', str(device)]) else: + # TLeconte fork RTL-SDR: -r cmd.extend(['-r', str(device)]) cmd.extend(frequencies) @@ -392,13 +415,13 @@ def stream_acars() -> Response: while True: try: - msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('acars', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) + msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('acars', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: diff --git a/routes/adsb.py b/routes/adsb.py index 0239c4a..98c6d69 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -38,6 +38,7 @@ from config import ( SHARED_OBSERVER_LOCATION_ENABLED, ) from utils.logging import adsb_logger as logger +from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090 from utils.validation import ( validate_device_index, validate_gain, validate_rtl_tcp_host, validate_rtl_tcp_port @@ -633,6 +634,9 @@ def start_adsb(): 'session': session }) + # Kill any stale app-spawned dump1090 from a previous run before checking the port + cleanup_stale_dump1090() + # Check if dump1090 is already running externally (e.g., user started it manually) existing_service = check_dump1090_service() if existing_service: @@ -685,6 +689,7 @@ def start_adsb(): except (ProcessLookupError, OSError): pass app_module.adsb_process = None + clear_dump1090_pid() logger.info("Killed stale ADS-B process") # Check if device is available before starting local dump1090 @@ -721,6 +726,7 @@ def start_adsb(): stderr=subprocess.PIPE, start_new_session=True # Create new process group for clean shutdown ) + write_dump1090_pid(app_module.adsb_process.pid) time.sleep(DUMP1090_START_WAIT) @@ -819,6 +825,7 @@ def stop_adsb(): except (ProcessLookupError, OSError): pass app_module.adsb_process = None + clear_dump1090_pid() logger.info("ADS-B process stopped") # Release device from registry diff --git a/routes/bluetooth_v2.py b/routes/bluetooth_v2.py index 393ff7a..d1fd306 100644 --- a/routes/bluetooth_v2.py +++ b/routes/bluetooth_v2.py @@ -229,7 +229,7 @@ def start_scan(): rssi_threshold = data.get('rssi_threshold', -100) # Validate mode - valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl') + valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth') if mode not in valid_modes: return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400 diff --git a/routes/gps.py b/routes/gps.py index 4685311..3952e9e 100644 --- a/routes/gps.py +++ b/routes/gps.py @@ -11,10 +11,14 @@ from flask import Blueprint, Response, jsonify from utils.gps import ( GPSPosition, GPSSkyData, + detect_gps_devices, get_current_position, get_gps_reader, + is_gpsd_running, start_gpsd, + start_gpsd_daemon, stop_gps, + stop_gpsd_daemon, ) from utils.logging import get_logger from utils.sse import format_sse @@ -58,10 +62,9 @@ def auto_connect_gps(): Automatically connect to gpsd if available. Called on page load to seamlessly enable GPS if gpsd is running. + If gpsd is not running, attempts to detect GPS devices and start gpsd. Returns current status if already connected. """ - import socket - # Check if already running reader = get_gps_reader() if reader and reader.is_running: @@ -75,21 +78,28 @@ def auto_connect_gps(): 'sky': sky.to_dict() if sky else None, }) - # Try to connect to gpsd on localhost:2947 host = 'localhost' port = 2947 - # First check if gpsd is reachable - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1.0) - sock.connect((host, port)) - sock.close() - except Exception: - return jsonify({ - 'status': 'unavailable', - 'message': 'gpsd not running' - }) + # If gpsd isn't running, try to detect a device and start it + if not is_gpsd_running(host, port): + devices = detect_gps_devices() + if not devices: + return jsonify({ + 'status': 'unavailable', + 'message': 'No GPS device detected' + }) + + # Try to start gpsd with the first detected device + device_path = devices[0]['path'] + success, msg = start_gpsd_daemon(device_path, host, port) + if not success: + return jsonify({ + 'status': 'unavailable', + 'message': msg, + 'devices': devices, + }) + logger.info(f"Auto-started gpsd on {device_path}") # Clear the queue while not _gps_queue.empty(): @@ -118,15 +128,26 @@ def auto_connect_gps(): }) +@gps_bp.route('/devices') +def list_gps_devices(): + """List detected GPS serial devices.""" + devices = detect_gps_devices() + return jsonify({ + 'devices': devices, + 'gpsd_running': is_gpsd_running(), + }) + + @gps_bp.route('/stop', methods=['POST']) def stop_gps_reader(): - """Stop GPS client.""" + """Stop GPS client and gpsd daemon if we started it.""" reader = get_gps_reader() if reader: reader.remove_callback(_position_callback) reader.remove_sky_callback(_sky_callback) stop_gps() + stop_gpsd_daemon() return jsonify({'status': 'stopped'}) diff --git a/routes/sensor.py b/routes/sensor.py index e2110fb..ec3de30 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -199,10 +199,16 @@ def start_sensor() -> Response: thread.start() # Monitor stderr + # Filter noisy rtl_433 diagnostics that aren't useful to display + _stderr_noise = ( + 'bitbuffer_add_bit', + 'row count limit', + ) + def monitor_stderr(): for line in app_module.sensor_process.stderr: err = line.decode('utf-8', errors='replace').strip() - if err: + if err and not any(noise in err for noise in _stderr_noise): logger.debug(f"[rtl_433] {err}") app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) diff --git a/setup.sh b/setup.sh index 9bd4f98..df307d5 100755 --- a/setup.sh +++ b/setup.sh @@ -137,6 +137,14 @@ need_sudo() { fi } +# Refresh sudo credential cache so long-running builds don't trigger +# mid-compilation password prompts (which can fail due to TTY issues +# inside subshells). Safe to call multiple times. +refresh_sudo() { + [[ -z "${SUDO:-}" ]] && return 0 + sudo -v 2>/dev/null || true +} + detect_os() { if [[ "${OSTYPE:-}" == "darwin"* ]]; then OS="macos" @@ -388,7 +396,7 @@ install_rtlamr_from_source() { if [[ -w /usr/local/bin ]]; then ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr else - sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr + $SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr fi else $SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr @@ -430,7 +438,8 @@ install_multimon_ng_from_source_macos() { if [[ -w /usr/local/bin ]]; then install -m 0755 multimon-ng /usr/local/bin/multimon-ng else - sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng + refresh_sudo + $SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng fi ok "multimon-ng installed successfully from source" ) @@ -471,7 +480,8 @@ install_dsd_from_source() { if [[ -w /usr/local/lib ]]; then make install >/dev/null 2>&1 else - sudo make install >/dev/null 2>&1 + refresh_sudo + $SUDO make install >/dev/null 2>&1 fi else $SUDO make install >/dev/null 2>&1 @@ -507,7 +517,8 @@ install_dsd_from_source() { if [[ -w /usr/local/bin ]]; then install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true else - sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true + refresh_sudo + $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true fi else $SUDO make install >/dev/null 2>&1 \ @@ -545,7 +556,8 @@ install_dump1090_from_source_macos() { if [[ -w /usr/local/bin ]]; then install -m 0755 dump1090 /usr/local/bin/dump1090 else - sudo install -m 0755 dump1090 /usr/local/bin/dump1090 + refresh_sudo + $SUDO install -m 0755 dump1090 /usr/local/bin/dump1090 fi ok "dump1090 installed successfully from source" else @@ -611,7 +623,8 @@ install_acarsdec_from_source_macos() { if [[ -w /usr/local/bin ]]; then install -m 0755 acarsdec /usr/local/bin/acarsdec else - sudo install -m 0755 acarsdec /usr/local/bin/acarsdec + refresh_sudo + $SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec fi ok "acarsdec installed successfully from source" else @@ -646,7 +659,8 @@ install_aiscatcher_from_source_macos() { if [[ -w /usr/local/bin ]]; then install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher else - sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher + refresh_sudo + $SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher fi ok "AIS-catcher installed successfully from source" else @@ -764,7 +778,8 @@ install_satdump_from_source_macos() { if [[ -w /usr/local/bin ]]; then make install >/dev/null 2>&1 else - sudo make install >/dev/null 2>&1 + refresh_sudo + $SUDO make install >/dev/null 2>&1 fi ok "SatDump installed successfully." else @@ -777,6 +792,14 @@ install_satdump_from_source_macos() { } install_macos_packages() { + need_sudo + + # Prime sudo credentials upfront so builds don't prompt mid-compilation + if [[ -n "${SUDO:-}" ]]; then + info "Some tools require sudo to install. You may be prompted for your password." + sudo -v || { fail "sudo authentication failed"; exit 1; } + fi + TOTAL_STEPS=19 CURRENT_STEP=0 diff --git a/static/css/index.css b/static/css/index.css index d485dd9..60498a5 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4119,7 +4119,7 @@ header h1 .tagline { border: 1px solid var(--border-color); border-radius: 6px; flex-shrink: 0; - height: 140px; + max-height: 340px; overflow: hidden; } @@ -4140,7 +4140,9 @@ header h1 .tagline { .bt-detail-body { padding: 8px 10px; - height: calc(100% - 30px); + height: auto; + max-height: calc(100% - 30px); + overflow-y: auto; } .bt-detail-placeholder { @@ -4319,6 +4321,110 @@ header h1 .tagline { color: #9fffd1; } +/* Service Data Inspector */ +.bt-detail-service-inspector { + margin-bottom: 6px; +} + +.bt-inspector-toggle { + font-size: 10px; + color: var(--accent-cyan); + cursor: pointer; + padding: 3px 0; + user-select: none; +} + +.bt-inspector-toggle:hover { + color: #fff; +} + +.bt-inspector-arrow { + display: inline-block; + transition: transform 0.2s; + font-size: 9px; +} + +.bt-inspector-arrow.open { + transform: rotate(90deg); +} + +.bt-inspector-content { + background: var(--bg-secondary); + border-radius: 3px; + padding: 6px 8px; + margin-top: 4px; + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + max-height: 100px; + overflow-y: auto; + word-break: break-all; +} + +.bt-inspector-row { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 2px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +.bt-inspector-row:last-child { + border-bottom: none; +} + +.bt-inspector-label { + color: var(--text-dim); + flex-shrink: 0; +} + +.bt-inspector-value { + color: var(--text-primary); + text-align: right; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +/* MAC Cluster Badge */ +.bt-mac-cluster-badge { + display: inline-block; + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; + font-size: 8px; + font-weight: 600; + padding: 1px 4px; + border-radius: 3px; + margin-left: 6px; + vertical-align: middle; +} + +/* Behavioral Flag Badges */ +.bt-flag-badge { + display: inline-block; + font-size: 8px; + font-weight: 600; + padding: 1px 4px; + border-radius: 3px; + margin-left: 3px; + vertical-align: middle; +} + +.bt-flag-badge.persistent { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.bt-flag-badge.beacon-like { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.bt-flag-badge.strong-stable { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + /* Selected device highlight */ .bt-device-row.selected { background: rgba(0, 212, 255, 0.1); @@ -4469,14 +4575,15 @@ header h1 .tagline { .bt-row-main { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; gap: 12px; } .bt-row-left { display: flex; - align-items: center; - gap: 8px; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 8px; min-width: 0; flex: 1; } @@ -4521,13 +4628,25 @@ header h1 .tagline { color: #22c55e; } +.bt-irk-badge { + display: inline-block; + padding: 1px 4px; + border-radius: 3px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; + background: rgba(168, 85, 247, 0.15); + color: #a855f7; + border: 1px solid rgba(168, 85, 247, 0.3); +} + .bt-device-name { font-size: 13px; font-weight: 600; color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + overflow-wrap: break-word; + word-break: break-word; + min-width: 0; } .bt-rssi-container { diff --git a/static/css/modes/gps.css b/static/css/modes/gps.css index 21850c1..0c25ef2 100644 --- a/static/css/modes/gps.css +++ b/static/css/modes/gps.css @@ -59,6 +59,11 @@ box-shadow: 0 0 6px rgba(255, 170, 0, 0.4); } +.gps-status-dot.error { + background: #ff4444; + box-shadow: 0 0 6px rgba(255, 68, 68, 0.4); +} + .gps-status-text { font-size: 11px; color: var(--text-secondary); diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index 9b7362b..9cd43d1 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -36,6 +36,7 @@ const ProximityRadar = (function() { let isHovered = false; let renderPending = false; let renderTimer = null; + let interactionLockUntil = 0; // timestamp: suppress renders briefly after click /** * Initialize the radar component @@ -119,6 +120,36 @@ const ProximityRadar = (function() { svg = container.querySelector('svg'); + // Event delegation on the devices group (survives innerHTML rebuilds) + const devicesGroup = svg.querySelector('.radar-devices'); + + devicesGroup.addEventListener('click', (e) => { + const deviceEl = e.target.closest('.radar-device'); + if (!deviceEl) return; + const deviceKey = deviceEl.getAttribute('data-device-key'); + if (onDeviceClick && deviceKey) { + // Lock out re-renders briefly so the DOM stays stable after click + interactionLockUntil = Date.now() + 500; + onDeviceClick(deviceKey); + } + }); + + devicesGroup.addEventListener('mouseenter', (e) => { + if (e.target.closest('.radar-device')) { + isHovered = true; + } + }, true); // capture phase so we catch enter on child elements + + devicesGroup.addEventListener('mouseleave', (e) => { + if (e.target.closest('.radar-device')) { + isHovered = false; + if (renderPending) { + renderPending = false; + renderDevices(); + } + } + }, true); + // Add sweep animation animateSweep(); } @@ -165,8 +196,8 @@ const ProximityRadar = (function() { devices.set(device.device_key, device); }); - // Defer render while user is hovering to prevent DOM rebuild flicker - if (isHovered) { + // Defer render while user is hovering or interacting to prevent DOM rebuild flicker + if (isHovered || Date.now() < interactionLockUntil) { renderPending = true; return; } @@ -229,7 +260,7 @@ const ProximityRadar = (function() { style="cursor: pointer;"> - ${isSelected ? ` + ${isSelected ? ` ` : ''} @@ -244,24 +275,6 @@ const ProximityRadar = (function() { }).join(''); devicesGroup.innerHTML = dots; - - // Attach event handlers - devicesGroup.querySelectorAll('.radar-device').forEach(el => { - el.addEventListener('click', (e) => { - const deviceKey = el.getAttribute('data-device-key'); - if (onDeviceClick && deviceKey) { - onDeviceClick(deviceKey); - } - }); - el.addEventListener('mouseenter', () => { isHovered = true; }); - el.addEventListener('mouseleave', () => { - isHovered = false; - if (renderPending) { - renderPending = false; - renderDevices(); - } - }); - }); } /** @@ -345,19 +358,125 @@ const ProximityRadar = (function() { } /** - * Highlight a specific device on the radar + * Highlight a specific device on the radar (in-place update, no full re-render) */ function highlightDevice(deviceKey) { + const prev = selectedDeviceKey; selectedDeviceKey = deviceKey; - renderDevices(); + + if (!svg) { return; } + const devicesGroup = svg.querySelector('.radar-devices'); + if (!devicesGroup) { return; } + + // Remove highlight from previously selected node + if (prev && prev !== deviceKey) { + const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`); + if (oldEl) { + oldEl.classList.remove('selected'); + // Remove animated selection ring + const ring = oldEl.querySelector('.radar-select-ring'); + if (ring) ring.remove(); + // Restore dot opacity + const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)'); + if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') { + const device = devices.get(prev); + const confidence = device ? (device.distance_confidence || 0.5) : 0.5; + dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5); + dot.setAttribute('stroke', dot.getAttribute('fill')); + dot.setAttribute('stroke-width', '1'); + } + } + } + + // Add highlight to newly selected node + if (deviceKey) { + const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`); + if (newEl) { + applySelectionToElement(newEl, deviceKey); + } else { + // Node not in DOM yet; full render needed on next cycle + renderDevices(); + } + } } /** - * Clear device highlighting + * Apply selection styling to a radar device element in-place + */ + function applySelectionToElement(el, deviceKey) { + el.classList.add('selected'); + const device = devices.get(deviceKey); + const confidence = device ? (device.distance_confidence || 0.5) : 0.5; + const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; + + // Update dot styling + const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)'); + if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') { + dot.setAttribute('fill-opacity', '1'); + dot.setAttribute('stroke', '#00d4ff'); + dot.setAttribute('stroke-width', '2'); + } + + // Add animated selection ring if not already present + if (!el.querySelector('.radar-select-ring')) { + const ns = 'http://www.w3.org/2000/svg'; + const ring = document.createElementNS(ns, 'circle'); + ring.classList.add('radar-select-ring'); + ring.setAttribute('r', dotSize + 8); + ring.setAttribute('fill', 'none'); + ring.setAttribute('stroke', '#00d4ff'); + ring.setAttribute('stroke-width', '2'); + ring.setAttribute('stroke-opacity', '0.8'); + + const animR = document.createElementNS(ns, 'animate'); + animR.setAttribute('attributeName', 'r'); + animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`); + animR.setAttribute('dur', '1.5s'); + animR.setAttribute('repeatCount', 'indefinite'); + ring.appendChild(animR); + + const animO = document.createElementNS(ns, 'animate'); + animO.setAttribute('attributeName', 'stroke-opacity'); + animO.setAttribute('values', '0.8;0.4;0.8'); + animO.setAttribute('dur', '1.5s'); + animO.setAttribute('repeatCount', 'indefinite'); + ring.appendChild(animO); + + // Insert after the hit area + const hitArea = el.querySelector('.radar-device-hitarea'); + if (hitArea && hitArea.nextSibling) { + el.insertBefore(ring, hitArea.nextSibling); + } else { + el.insertBefore(ring, el.firstChild); + } + } + } + + /** + * Clear device highlighting (in-place update, no full re-render) */ function clearHighlight() { + const prev = selectedDeviceKey; selectedDeviceKey = null; - renderDevices(); + + if (!svg || !prev) { return; } + const devicesGroup = svg.querySelector('.radar-devices'); + if (!devicesGroup) { return; } + + const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`); + if (oldEl) { + oldEl.classList.remove('selected'); + const ring = oldEl.querySelector('.radar-select-ring'); + if (ring) ring.remove(); + const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)'); + if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') { + const device = devices.get(prev); + const confidence = device ? (device.distance_confidence || 0.5) : 0.5; + dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5); + dot.setAttribute('stroke', dot.getAttribute('fill')); + dot.setAttribute('stroke-width', '1'); + } + } } /** diff --git a/static/js/components/signal-cards.js b/static/js/components/signal-cards.js index d7505d6..07e81f8 100644 --- a/static/js/components/signal-cards.js +++ b/static/js/components/signal-cards.js @@ -302,7 +302,13 @@ const SignalCards = (function() { */ function formatRelativeTime(timestamp) { if (!timestamp) return ''; - const date = new Date(timestamp); + let date = new Date(timestamp); + // Handle time-only strings like "HH:MM:SS" (from pager/sensor backends) + if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) { + const today = new Date(); + date = new Date(today.toDateString() + ' ' + timestamp); + } + if (isNaN(date.getTime())) return timestamp; const now = new Date(); const diff = Math.floor((now - date) / 1000); diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 7968b9f..99ad162 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -356,7 +356,9 @@ const BluetoothMode = (function() { // Update panel elements document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address); - document.getElementById('btDetailAddress').textContent = device.address; + document.getElementById('btDetailAddress').textContent = isUuidAddress(device) + ? 'CB: ' + device.address + : device.address; // RSSI const rssiEl = document.getElementById('btDetailRssi'); @@ -458,8 +460,98 @@ const BluetoothMode = (function() { ? new Date(device.last_seen).toLocaleTimeString() : '--'; + // New stat cells + document.getElementById('btDetailTxPower').textContent = device.tx_power != null + ? device.tx_power + ' dBm' : '--'; + document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null + ? device.seen_rate.toFixed(1) + '/min' : '--'; + + // Stability from variance + const stabilityEl = document.getElementById('btDetailStability'); + if (device.rssi_variance != null) { + let stabLabel, stabColor; + if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; } + else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; } + else { stabLabel = 'Unstable'; stabColor = '#ef4444'; } + stabilityEl.textContent = stabLabel; + stabilityEl.style.color = stabColor; + } else { + stabilityEl.textContent = '--'; + stabilityEl.style.color = ''; + } + + // Distance with confidence + const distEl = document.getElementById('btDetailDistance'); + if (device.estimated_distance_m != null) { + const confPct = Math.round((device.distance_confidence || 0) * 100); + distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%'; + } else { + distEl.textContent = '--'; + } + + // Appearance badge + if (device.appearance_name) { + badgesHtml += '' + escapeHtml(device.appearance_name) + ''; + badgesEl.innerHTML = badgesHtml; + } + + // MAC cluster indicator + const macClusterEl = document.getElementById('btDetailMacCluster'); + if (macClusterEl) { + if (device.mac_cluster_count > 1) { + macClusterEl.textContent = device.mac_cluster_count + ' MACs'; + macClusterEl.style.display = ''; + } else { + macClusterEl.style.display = 'none'; + } + } + + // Service data inspector + const inspectorEl = document.getElementById('btDetailServiceInspector'); + const inspectorContent = document.getElementById('btInspectorContent'); + if (inspectorEl && inspectorContent) { + const hasData = device.manufacturer_bytes || device.appearance != null || + (device.service_data && Object.keys(device.service_data).length > 0); + if (hasData) { + inspectorEl.style.display = ''; + let inspHtml = ''; + if (device.appearance != null) { + const name = device.appearance_name || ''; + inspHtml += '
Appearance0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '
'; + } + if (device.manufacturer_bytes) { + inspHtml += '
Mfr Data' + escapeHtml(device.manufacturer_bytes) + '
'; + } + if (device.service_data) { + Object.entries(device.service_data).forEach(([uuid, hex]) => { + inspHtml += '
' + escapeHtml(uuid) + '' + escapeHtml(hex) + '
'; + }); + } + inspectorContent.innerHTML = inspHtml; + } else { + inspectorEl.style.display = 'none'; + } + } + updateWatchlistButton(device); + // IRK + const irkContainer = document.getElementById('btDetailIrk'); + if (irkContainer) { + if (device.has_irk) { + irkContainer.style.display = 'block'; + const irkVal = document.getElementById('btDetailIrkValue'); + if (irkVal) { + const label = device.irk_source_name + ? device.irk_source_name + ' — ' + device.irk_hex + : device.irk_hex; + irkVal.textContent = label; + } + } else { + irkContainer.style.display = 'none'; + } + } + // Services const servicesContainer = document.getElementById('btDetailServices'); const servicesList = document.getElementById('btDetailServicesList'); @@ -600,9 +692,25 @@ const BluetoothMode = (function() { if (parts.length === 6) { return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5]; } + // CoreBluetooth UUID format (8-4-4-4-12) + if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) { + return address.substring(0, 8) + '...'; + } return address; } + function isUuidAddress(device) { + return device.address_type === 'uuid'; + } + + function formatAddress(device) { + if (!device || !device.address) return '--'; + if (isUuidAddress(device)) { + return device.address.substring(0, 8) + '-...' + device.address.slice(-4); + } + return device.address; + } + /** * Check system capabilities */ @@ -660,6 +768,12 @@ const BluetoothMode = (function() { hideCapabilityWarning(); } + // Show/hide Ubertooth option based on capabilities + const ubertoothOption = document.getElementById('btScanModeUbertooth'); + if (ubertoothOption) { + ubertoothOption.style.display = data.has_ubertooth ? '' : 'none'; + } + if (scanModeSelect && data.preferred_backend) { const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`); if (option) option.selected = true; @@ -1085,7 +1199,7 @@ const BluetoothMode = (function() { '' + '' + '
' + - '' + t.address + '' + + '' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '' + 'Seen ' + (t.seen_count || 0) + 'x' + '
' + evidenceHtml + @@ -1142,7 +1256,7 @@ const BluetoothMode = (function() { const displayName = device.name || formatDeviceId(device.address); const name = escapeHtml(displayName); - const addr = escapeHtml(device.address || 'Unknown'); + const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; const seenCount = device.seen_count || 0; const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'"); @@ -1167,6 +1281,12 @@ const BluetoothMode = (function() { trackerBadge = '' + typeLabel + ''; } + // IRK badge - show if paired IRK is available + let irkBadge = ''; + if (device.has_irk) { + irkBadge = 'IRK'; + } + // Risk badge - show if risk score is significant let riskBadge = ''; if (riskScore >= 0.3) { @@ -1184,9 +1304,36 @@ const BluetoothMode = (function() { statusDot = ''; } + // Distance display + const distM = device.estimated_distance_m; + let distStr = ''; + if (distM != null) { + distStr = '~' + distM.toFixed(1) + 'm'; + } + + // Behavioral flag badges + const hFlags = device.heuristic_flags || []; + let flagBadges = ''; + if (device.is_persistent || hFlags.includes('persistent')) { + flagBadges += 'PERSIST'; + } + if (device.is_beacon_like || hFlags.includes('beacon_like')) { + flagBadges += 'BEACON'; + } + if (device.is_strong_stable || hFlags.includes('strong_stable')) { + flagBadges += 'STABLE'; + } + + // MAC cluster badge + let clusterBadge = ''; + if (device.mac_cluster_count > 1) { + clusterBadge = '' + device.mac_cluster_count + ' MACs'; + } + // Build secondary info line let secondaryParts = [addr]; if (mfr) secondaryParts.push(mfr); + if (distStr) secondaryParts.push(distStr); secondaryParts.push('Seen ' + seenCount + '×'); if (seenBefore) secondaryParts.push('SEEN BEFORE'); // Add agent name if not Local @@ -1205,7 +1352,10 @@ const BluetoothMode = (function() { protoBadge + '' + name + '' + trackerBadge + + irkBadge + riskBadge + + flagBadges + + clusterBadge + '' + '
' + '
' + @@ -1300,6 +1450,18 @@ const BluetoothMode = (function() { } } + /** + * Toggle the service data inspector panel + */ + function toggleServiceInspector() { + const content = document.getElementById('btInspectorContent'); + const arrow = document.getElementById('btInspectorArrow'); + if (!content) return; + const open = content.style.display === 'none'; + content.style.display = open ? '' : 'none'; + if (arrow) arrow.classList.toggle('open', open); + } + // ========================================================================== // Agent Handling // ========================================================================== @@ -1425,9 +1587,15 @@ const BluetoothMode = (function() { BtLocate.handoff({ device_id: device.device_id, mac_address: device.address, + address_type: device.address_type || null, + irk_hex: device.irk_hex || null, known_name: device.name || null, known_manufacturer: device.manufacturer_name || null, - last_known_rssi: device.rssi_current + last_known_rssi: device.rssi_current, + tx_power: device.tx_power || null, + appearance_name: device.appearance_name || null, + fingerprint_id: device.fingerprint_id || null, + mac_cluster_count: device.mac_cluster_count || 0 }); } } @@ -1447,6 +1615,7 @@ const BluetoothMode = (function() { toggleWatchlist, locateDevice, locateById, + toggleServiceInspector, // Agent handling handleAgentChange, diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 41b822a..c611d70 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -322,7 +322,8 @@ const BtLocate = (function() { const t = data.target; const name = t.known_name || t.name_pattern || ''; const addr = t.mac_address || t.device_id || ''; - targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--'; + const addrDisplay = formatAddr(addr); + targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--'; } // Environment info @@ -602,6 +603,16 @@ const BtLocate = (function() { }).catch(() => {}); } + function isUuid(addr) { + return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr); + } + + function formatAddr(addr) { + if (!addr) return ''; + if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4); + return addr; + } + function handoff(deviceInfo) { console.log('[BtLocate] Handoff received:', deviceInfo); handoffData = deviceInfo; @@ -617,15 +628,21 @@ const BtLocate = (function() { const nameEl = document.getElementById('btLocateHandoffName'); const metaEl = document.getElementById('btLocateHandoffMeta'); if (card) card.style.display = ''; - if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown'; + if (nameEl) nameEl.textContent = deviceInfo.known_name || formatAddr(deviceInfo.mac_address) || 'Unknown'; if (metaEl) { const parts = []; - if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address); + if (deviceInfo.mac_address) parts.push(formatAddr(deviceInfo.mac_address)); if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer); if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm'); metaEl.textContent = parts.join(' \u00b7 '); } + // Auto-fill IRK if available from scanner + if (deviceInfo.irk_hex) { + const irkInput = document.getElementById('btLocateIrk'); + if (irkInput) irkInput.value = deviceInfo.irk_hex; + } + // Switch to bt_locate mode if (typeof switchMode === 'function') { switchMode('bt_locate'); diff --git a/static/js/modes/gps.js b/static/js/modes/gps.js index a65db27..1e1c7b2 100644 --- a/static/js/modes/gps.js +++ b/static/js/modes/gps.js @@ -5,7 +5,6 @@ */ const GPS = (function() { - let eventSource = null; let connected = false; let lastPosition = null; let lastSky = null; @@ -26,6 +25,7 @@ const GPS = (function() { } function connect() { + updateConnectionUI(false, false, 'connecting'); fetch('/gps/auto-connect', { method: 'POST' }) .then(r => r.json()) .then(data => { @@ -40,23 +40,24 @@ const GPS = (function() { lastSky = data.sky; updateSkyUI(data.sky); } - startStream(); + subscribeToStream(); + // Ensure the global GPS stream is running + if (typeof startGpsStream === 'function' && !gpsEventSource) { + startGpsStream(); + } } else { connected = false; - updateConnectionUI(false); + updateConnectionUI(false, false, 'error', data.message || 'gpsd not available'); } }) .catch(() => { connected = false; - updateConnectionUI(false); + updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?'); }); } function disconnect() { - if (eventSource) { - eventSource.close(); - eventSource = null; - } + unsubscribeFromStream(); fetch('/gps/stop', { method: 'POST' }) .then(() => { connected = false; @@ -64,36 +65,36 @@ const GPS = (function() { }); } - function startStream() { - if (eventSource) { - eventSource.close(); + function onGpsStreamData(data) { + if (!connected) return; + if (data.type === 'position') { + lastPosition = data; + updatePositionUI(data); + updateConnectionUI(true, true); + } else if (data.type === 'sky') { + lastSky = data; + updateSkyUI(data); + } + } + + function subscribeToStream() { + // Subscribe to the global GPS stream instead of opening a separate SSE connection + if (typeof addGpsStreamSubscriber === 'function') { + addGpsStreamSubscriber(onGpsStreamData); + } + } + + function unsubscribeFromStream() { + if (typeof removeGpsStreamSubscriber === 'function') { + removeGpsStreamSubscriber(onGpsStreamData); } - eventSource = new EventSource('/gps/stream'); - eventSource.onmessage = function(e) { - try { - const data = JSON.parse(e.data); - if (data.type === 'position') { - lastPosition = data; - updatePositionUI(data); - updateConnectionUI(true, true); - } else if (data.type === 'sky') { - lastSky = data; - updateSkyUI(data); - } - } catch (err) { - // ignore parse errors - } - }; - eventSource.onerror = function() { - // Reconnect handled by browser automatically - }; } // ======================== // UI Updates // ======================== - function updateConnectionUI(isConnected, hasFix) { + function updateConnectionUI(isConnected, hasFix, state, message) { const dot = document.getElementById('gpsStatusDot'); const text = document.getElementById('gpsStatusText'); const connectBtn = document.getElementById('gpsConnectBtn'); @@ -102,15 +103,22 @@ const GPS = (function() { if (dot) { dot.className = 'gps-status-dot'; - if (isConnected && hasFix) dot.classList.add('connected'); + if (state === 'connecting') dot.classList.add('waiting'); + else if (state === 'error') dot.classList.add('error'); + else if (isConnected && hasFix) dot.classList.add('connected'); else if (isConnected) dot.classList.add('waiting'); } if (text) { - if (isConnected && hasFix) text.textContent = 'Connected (Fix)'; + if (state === 'connecting') text.textContent = 'Connecting...'; + else if (state === 'error') text.textContent = message || 'Connection failed'; + else if (isConnected && hasFix) text.textContent = 'Connected (Fix)'; else if (isConnected) text.textContent = 'Connected (No Fix)'; else text.textContent = 'Disconnected'; } - if (connectBtn) connectBtn.style.display = isConnected ? 'none' : ''; + if (connectBtn) { + connectBtn.style.display = isConnected ? 'none' : ''; + connectBtn.disabled = state === 'connecting'; + } if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none'; if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : ''; } @@ -386,10 +394,7 @@ const GPS = (function() { // ======================== function destroy() { - if (eventSource) { - eventSource.close(); - eventSource = null; - } + unsubscribeFromStream(); } return { diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index cfd329a..79fcb55 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -3869,7 +3869,9 @@ sudo make install } function startAcars() { - const device = document.getElementById('acarsDeviceSelect').value; + const acarsSelect = document.getElementById('acarsDeviceSelect'); + const device = acarsSelect.value; + const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; const frequencies = getAcarsRegionFreqs(); // Check if using agent mode @@ -3895,7 +3897,7 @@ sudo make install fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ device, frequencies, gain: '40' }) + body: JSON.stringify({ device, frequencies, gain: '40', sdr_type }) }) .then(r => r.json()) .then(data => { @@ -4101,6 +4103,7 @@ sudo make install devices.forEach((d, i) => { const opt = document.createElement('option'); opt.value = d.index || i; + opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; select.appendChild(opt); }); @@ -4880,6 +4883,7 @@ sudo make install devices.forEach(device => { const opt = document.createElement('option'); opt.value = device.index; + opt.dataset.sdrType = device.sdr_type || 'rtlsdr'; opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`; select.appendChild(opt); }); diff --git a/templates/index.html b/templates/index.html index f17e03e..73adbba 100644 --- a/templates/index.html +++ b/templates/index.html @@ -216,6 +216,10 @@ Bluetooth +
@@ -439,6 +443,7 @@
diff --git a/utils/bluetooth/aggregator.py b/utils/bluetooth/aggregator.py index 3e76933..fad75de 100644 --- a/utils/bluetooth/aggregator.py +++ b/utils/bluetooth/aggregator.py @@ -606,6 +606,12 @@ class DeviceAggregator: return result + def get_fingerprint_mac_count(self, fingerprint_id: str) -> int: + """Return how many distinct device_ids share a fingerprint.""" + with self._lock: + device_ids = self._fingerprint_to_devices.get(fingerprint_id) + return len(device_ids) if device_ids else 0 + def prune_ring_buffer(self) -> int: """Prune old observations from ring buffer.""" return self._ring_buffer.prune_old() diff --git a/utils/bluetooth/constants.py b/utils/bluetooth/constants.py index 9a93a68..ec289ff 100644 --- a/utils/bluetooth/constants.py +++ b/utils/bluetooth/constants.py @@ -101,6 +101,7 @@ ADDRESS_TYPE_RANDOM = 'random' ADDRESS_TYPE_RANDOM_STATIC = 'random_static' ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address +ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available) # ============================================================================= # PROTOCOL TYPES @@ -278,3 +279,59 @@ MINOR_WEARABLE = { 0x04: 'Helmet', 0x05: 'Glasses', } + +# ============================================================================= +# BLE APPEARANCE CODES (GAP Appearance values) +# ============================================================================= + +BLE_APPEARANCE_NAMES: dict[int, str] = { + 0: 'Unknown', + 64: 'Phone', + 128: 'Computer', + 192: 'Watch', + 193: 'Sports Watch', + 256: 'Clock', + 320: 'Display', + 384: 'Remote Control', + 448: 'Eye Glasses', + 512: 'Tag', + 576: 'Keyring', + 640: 'Media Player', + 704: 'Barcode Scanner', + 768: 'Thermometer', + 832: 'Heart Rate Sensor', + 896: 'Blood Pressure', + 960: 'HID', + 961: 'Keyboard', + 962: 'Mouse', + 963: 'Joystick', + 964: 'Gamepad', + 965: 'Digitizer Tablet', + 966: 'Card Reader', + 967: 'Digital Pen', + 968: 'Barcode Scanner (HID)', + 1024: 'Glucose Monitor', + 1088: 'Running Speed Sensor', + 1152: 'Cycling', + 1216: 'Control Device', + 1280: 'Network Device', + 1344: 'Sensor', + 1408: 'Light Fixture', + 1472: 'Fan', + 1536: 'HVAC', + 1600: 'Access Control', + 1664: 'Motorized Device', + 1728: 'Power Device', + 1792: 'Light Source', + 3136: 'Pulse Oximeter', + 3200: 'Weight Scale', + 3264: 'Personal Mobility', + 5184: 'Outdoor Sports Activity', +} + + +def get_appearance_name(code: int | None) -> str | None: + """Look up a human-readable name for a BLE appearance code.""" + if code is None: + return None + return BLE_APPEARANCE_NAMES.get(code) diff --git a/utils/bluetooth/device_key.py b/utils/bluetooth/device_key.py index e3885ee..45c90d0 100644 --- a/utils/bluetooth/device_key.py +++ b/utils/bluetooth/device_key.py @@ -12,6 +12,7 @@ from typing import Optional from .constants import ( ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, + ADDRESS_TYPE_UUID, ) @@ -46,10 +47,14 @@ def generate_device_key( if identity_address: return f"id:{identity_address.upper()}" - # Priority 2: Use public or random_static addresses directly + # Priority 2: Use public or random_static addresses directly (not platform UUIDs) if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC): return f"mac:{address.upper()}" + # Priority 2b: CoreBluetooth UUIDs are stable per-system, use as identifier + if address_type == ADDRESS_TYPE_UUID: + return f"uuid:{address.upper()}" + # Priority 3: Generate fingerprint hash for random addresses return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids) @@ -102,7 +107,7 @@ def is_randomized_mac(address_type: str) -> bool: Returns: True if the address is randomized, False otherwise. """ - return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC) + return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_UUID) def extract_key_type(device_key: str) -> str: diff --git a/utils/bluetooth/fallback_scanner.py b/utils/bluetooth/fallback_scanner.py index 5b5b54e..efa2065 100644 --- a/utils/bluetooth/fallback_scanner.py +++ b/utils/bluetooth/fallback_scanner.py @@ -24,8 +24,12 @@ from .constants import ( BLUETOOTHCTL_TIMEOUT, ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM, + ADDRESS_TYPE_UUID, MANUFACTURER_NAMES, ) + +# CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits +_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$') from .models import BTObservation logger = logging.getLogger(__name__) @@ -132,7 +136,10 @@ class BleakScanner: """Convert bleak device to BTObservation.""" # Determine address type from address format address_type = ADDRESS_TYPE_PUBLIC - if device.address and ':' in device.address: + if device.address and _CB_UUID_RE.match(device.address): + # macOS CoreBluetooth returns a platform UUID instead of a real MAC + address_type = ADDRESS_TYPE_UUID + elif device.address and ':' in device.address: # Check if first byte indicates random address first_byte = int(device.address.split(':')[0], 16) if (first_byte & 0xC0) == 0xC0: # Random static diff --git a/utils/bluetooth/models.py b/utils/bluetooth/models.py index 2810819..a8d59cf 100644 --- a/utils/bluetooth/models.py +++ b/utils/bluetooth/models.py @@ -18,6 +18,7 @@ from .constants import ( RANGE_UNKNOWN, PROTOCOL_BLE, PROXIMITY_UNKNOWN, + get_appearance_name, ) # Import tracker types (will be available after tracker_signatures module loads) @@ -148,10 +149,10 @@ class BTDeviceAggregate: is_strong_stable: bool = False has_random_address: bool = False - # Baseline tracking - in_baseline: bool = False - baseline_id: Optional[int] = None - seen_before: bool = False + # Baseline tracking + in_baseline: bool = False + baseline_id: Optional[int] = None + seen_before: bool = False # Tracker detection fields is_tracker: bool = False @@ -165,6 +166,10 @@ class BTDeviceAggregate: risk_score: float = 0.0 # 0.0 to 1.0 risk_factors: list[str] = field(default_factory=list) + # IRK (Identity Resolving Key) from paired device database + irk_hex: Optional[str] = None # 32-char hex if known + irk_source_name: Optional[str] = None # Name from paired DB + # Payload fingerprint (survives MAC randomization) payload_fingerprint_id: Optional[str] = None payload_fingerprint_stability: float = 0.0 @@ -275,10 +280,10 @@ class BTDeviceAggregate: }, 'heuristic_flags': self.heuristic_flags, - # Baseline - 'in_baseline': self.in_baseline, - 'baseline_id': self.baseline_id, - 'seen_before': self.seen_before, + # Baseline + 'in_baseline': self.in_baseline, + 'baseline_id': self.baseline_id, + 'seen_before': self.seen_before, # Tracker detection 'tracker': { @@ -296,6 +301,11 @@ class BTDeviceAggregate: 'risk_factors': self.risk_factors, }, + # IRK + 'has_irk': self.irk_hex is not None, + 'irk_hex': self.irk_hex, + 'irk_source_name': self.irk_source_name, + # Fingerprint 'fingerprint': { 'id': self.payload_fingerprint_id, @@ -319,24 +329,46 @@ class BTDeviceAggregate: 'rssi_current': self.rssi_current, 'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None, 'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None, + 'rssi_min': self.rssi_min, + 'rssi_max': self.rssi_max, + 'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None, 'range_band': self.range_band, 'proximity_band': self.proximity_band, 'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, 'distance_confidence': round(self.distance_confidence, 2), 'is_randomized_mac': self.is_randomized_mac, 'last_seen': self.last_seen.isoformat(), + 'first_seen': self.first_seen.isoformat(), 'age_seconds': self.age_seconds, + 'duration_seconds': self.duration_seconds, 'seen_count': self.seen_count, - 'heuristic_flags': self.heuristic_flags, - 'in_baseline': self.in_baseline, - 'seen_before': self.seen_before, - # Tracker info for list view - 'is_tracker': self.is_tracker, + 'seen_rate': round(self.seen_rate, 2), + 'tx_power': self.tx_power, + 'manufacturer_id': self.manufacturer_id, + 'appearance': self.appearance, + 'appearance_name': get_appearance_name(self.appearance), + 'is_connectable': self.is_connectable, + 'service_uuids': self.service_uuids, + 'service_data': {k: v.hex() for k, v in self.service_data.items()}, + 'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None, + 'heuristic_flags': self.heuristic_flags, + 'is_persistent': self.is_persistent, + 'is_beacon_like': self.is_beacon_like, + 'is_strong_stable': self.is_strong_stable, + 'in_baseline': self.in_baseline, + 'seen_before': self.seen_before, + # Tracker info for list view + 'is_tracker': self.is_tracker, 'tracker_type': self.tracker_type, 'tracker_name': self.tracker_name, 'tracker_confidence': self.tracker_confidence, 'tracker_confidence_score': round(self.tracker_confidence_score, 2), + 'tracker_evidence': self.tracker_evidence, 'risk_score': round(self.risk_score, 2), + 'risk_factors': self.risk_factors, + 'has_irk': self.irk_hex is not None, + 'irk_hex': self.irk_hex, + 'irk_source_name': self.irk_source_name, 'fingerprint_id': self.payload_fingerprint_id, } diff --git a/utils/bluetooth/scanner.py b/utils/bluetooth/scanner.py index 1bb2a66..db49d2b 100644 --- a/utils/bluetooth/scanner.py +++ b/utils/bluetooth/scanner.py @@ -24,7 +24,9 @@ from .constants import ( ) from .dbus_scanner import DBusScanner from .fallback_scanner import FallbackScanner +from .ubertooth_scanner import UbertoothScanner from .heuristics import HeuristicsEngine +from .irk_extractor import get_paired_irks from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities logger = logging.getLogger(__name__) @@ -57,6 +59,7 @@ class BluetoothScanner: # Scanner backends self._dbus_scanner: Optional[DBusScanner] = None self._fallback_scanner: Optional[FallbackScanner] = None + self._ubertooth_scanner: Optional[UbertoothScanner] = None self._active_backend: Optional[str] = None # Event queue for SSE streaming @@ -113,6 +116,8 @@ class BluetoothScanner: if mode == 'dbus': started, backend_used = self._start_dbus(adapter, transport, rssi_threshold) + elif mode == 'ubertooth': + started, backend_used = self._start_ubertooth() # Fallback: try non-DBus methods if DBus failed or wasn't requested if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')): @@ -168,6 +173,18 @@ class BluetoothScanner: logger.warning(f"DBus scanner failed: {e}") return False, None + def _start_ubertooth(self) -> tuple[bool, Optional[str]]: + """Start Ubertooth One scanner.""" + try: + self._ubertooth_scanner = UbertoothScanner( + on_observation=self._handle_observation, + ) + if self._ubertooth_scanner.start(): + return True, 'ubertooth' + except Exception as e: + logger.warning(f"Ubertooth scanner failed: {e}") + return False, None + def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]: """Start fallback scanner.""" try: @@ -204,6 +221,10 @@ class BluetoothScanner: self._fallback_scanner.stop() self._fallback_scanner = None + if self._ubertooth_scanner: + self._ubertooth_scanner.stop() + self._ubertooth_scanner = None + # Update status self._status.is_scanning = False self._active_backend = None @@ -216,6 +237,47 @@ class BluetoothScanner: logger.info("Bluetooth scan stopped") + def _match_irk(self, device: BTDeviceAggregate) -> None: + """Check if a device address resolves against any paired IRK.""" + if device.irk_hex is not None: + return # Already matched + + address = device.address + if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32): + return + + # Only attempt RPA resolution on 6-byte addresses + addr_clean = address.replace(':', '').replace('-', '') + if len(addr_clean) != 12: + return + + try: + paired = get_paired_irks() + except Exception: + return + + if not paired: + return + + try: + from utils.bt_locate import resolve_rpa + except ImportError: + return + + for entry in paired: + irk_hex = entry.get('irk_hex', '') + if not irk_hex or len(irk_hex) != 32: + continue + try: + irk = bytes.fromhex(irk_hex) + if resolve_rpa(irk, address): + device.irk_hex = irk_hex + device.irk_source_name = entry.get('name') + logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}") + return + except Exception: + continue + def _handle_observation(self, observation: BTObservation) -> None: """Handle incoming observation from scanner backend.""" try: @@ -225,15 +287,27 @@ class BluetoothScanner: # Evaluate heuristics self._heuristics.evaluate(device) + # Check for IRK match + self._match_irk(device) + # Update device count with self._lock: self._status.devices_found = self._aggregator.device_count + # Build summary with MAC cluster count + summary = device.to_summary_dict() + if device.payload_fingerprint_id: + summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count( + device.payload_fingerprint_id + ) + else: + summary['mac_cluster_count'] = 0 + # Queue event self._queue_event({ 'type': 'device', 'action': 'update', - 'device': device.to_summary_dict(), + 'device': summary, }) # Callbacks @@ -398,6 +472,7 @@ class BluetoothScanner: backend_alive = ( (self._dbus_scanner and self._dbus_scanner.is_scanning) or (self._fallback_scanner and self._fallback_scanner.is_scanning) + or (self._ubertooth_scanner and self._ubertooth_scanner.is_scanning) ) if not backend_alive: self._status.is_scanning = False diff --git a/utils/gps.py b/utils/gps.py index 7f7f742..e80a749 100644 --- a/utils/gps.py +++ b/utils/gps.py @@ -483,3 +483,181 @@ def get_current_position() -> GPSPosition | None: if client: return client.position return None + + +# ============================================ +# GPS device detection and gpsd auto-start +# ============================================ + +_gpsd_process: 'subprocess.Popen | None' = None +_gpsd_process_lock = threading.Lock() + + +def detect_gps_devices() -> list[dict]: + """ + Detect connected GPS serial devices. + + Returns list of dicts with 'path' and 'description' keys. + """ + import glob + import os + import platform + + devices: list[dict] = [] + system = platform.system() + + if system == 'Linux': + # Common USB GPS device paths + patterns = ['/dev/ttyUSB*', '/dev/ttyACM*'] + for pattern in patterns: + for path in sorted(glob.glob(pattern)): + desc = _describe_device_linux(path) + devices.append({'path': path, 'description': desc}) + + # Also check /dev/serial/by-id for descriptive names + serial_dir = '/dev/serial/by-id' + if os.path.isdir(serial_dir): + for name in sorted(os.listdir(serial_dir)): + full = os.path.join(serial_dir, name) + real = os.path.realpath(full) + # Skip if we already found this device + if any(d['path'] == real for d in devices): + # Update description with the more descriptive name + for d in devices: + if d['path'] == real: + d['description'] = name + continue + devices.append({'path': real, 'description': name}) + + elif system == 'Darwin': + # macOS: USB serial devices (prefer cu. over tty. for outgoing) + patterns = ['/dev/cu.usbmodem*', '/dev/cu.usbserial*'] + for pattern in patterns: + for path in sorted(glob.glob(pattern)): + desc = _describe_device_macos(path) + devices.append({'path': path, 'description': desc}) + + # Sort: devices with GPS-related descriptions first + gps_keywords = ('gps', 'gnss', 'u-blox', 'ublox', 'nmea', 'sirf', 'navigation') + devices.sort(key=lambda d: ( + 0 if any(k in d['description'].lower() for k in gps_keywords) else 1 + )) + + return devices + + +def _describe_device_linux(path: str) -> str: + """Get a human-readable description of a Linux serial device.""" + import os + basename = os.path.basename(path) + # Try to read from sysfs + try: + # /sys/class/tty/ttyUSB0/device/../product + sysfs = f'/sys/class/tty/{basename}/device/../product' + if os.path.exists(sysfs): + with open(sysfs) as f: + return f.read().strip() + except Exception: + pass + return basename + + +def _describe_device_macos(path: str) -> str: + """Get a description of a macOS serial device.""" + import os + return os.path.basename(path) + + +def is_gpsd_running(host: str = 'localhost', port: int = 2947) -> bool: + """Check if gpsd is reachable.""" + import socket + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1.0) + sock.connect((host, port)) + sock.close() + return True + except Exception: + return False + + +def start_gpsd_daemon(device_path: str, host: str = 'localhost', + port: int = 2947) -> tuple[bool, str]: + """ + Start gpsd daemon pointing at the given device. + + Returns (success, message) tuple. + """ + import shutil + import subprocess + + global _gpsd_process + + with _gpsd_process_lock: + # Already running? + if is_gpsd_running(host, port): + return True, 'gpsd already running' + + gpsd_bin = shutil.which('gpsd') + if not gpsd_bin: + return False, 'gpsd not installed' + + # Stop any existing managed process + stop_gpsd_daemon() + + try: + import os + if not os.path.exists(device_path): + return False, f'Device {device_path} not found' + + cmd = [gpsd_bin, '-N', '-n', '-S', str(port), device_path] + logger.info(f"Starting gpsd: {' '.join(cmd)}") + print(f"[GPS] Starting gpsd: {' '.join(cmd)}", flush=True) + + _gpsd_process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + # Give gpsd a moment to start + import time + time.sleep(1.5) + + if _gpsd_process.poll() is not None: + stderr = '' + if _gpsd_process.stderr: + stderr = _gpsd_process.stderr.read().decode('utf-8', errors='ignore').strip() + msg = f'gpsd exited with code {_gpsd_process.returncode}' + if stderr: + msg += f': {stderr}' + return False, msg + + # Verify it's listening + if is_gpsd_running(host, port): + return True, f'gpsd started on {device_path}' + else: + return False, 'gpsd started but not accepting connections' + + except Exception as e: + logger.error(f"Failed to start gpsd: {e}") + return False, str(e) + + +def stop_gpsd_daemon() -> None: + """Stop the managed gpsd daemon process.""" + global _gpsd_process + + with _gpsd_process_lock: + if _gpsd_process and _gpsd_process.poll() is None: + try: + _gpsd_process.terminate() + _gpsd_process.wait(timeout=3.0) + except Exception: + try: + _gpsd_process.kill() + except Exception: + pass + logger.info("Stopped gpsd daemon") + print("[GPS] Stopped gpsd daemon", flush=True) + _gpsd_process = None diff --git a/utils/process.py b/utils/process.py index 79a5796..c177fd8 100644 --- a/utils/process.py +++ b/utils/process.py @@ -2,11 +2,14 @@ from __future__ import annotations import atexit import logging +import os +import platform import signal import subprocess import re import threading import time +from pathlib import Path from typing import Any, Callable from .dependencies import check_tool @@ -117,6 +120,93 @@ def cleanup_stale_processes() -> None: pass +_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid' + + +def write_dump1090_pid(pid: int) -> None: + """Write the PID of an app-spawned dump1090 process to a PID file.""" + try: + _DUMP1090_PID_FILE.parent.mkdir(parents=True, exist_ok=True) + _DUMP1090_PID_FILE.write_text(str(pid)) + logger.debug(f"Wrote dump1090 PID file: {pid}") + except OSError as e: + logger.warning(f"Failed to write dump1090 PID file: {e}") + + +def clear_dump1090_pid() -> None: + """Remove the dump1090 PID file.""" + try: + _DUMP1090_PID_FILE.unlink(missing_ok=True) + logger.debug("Cleared dump1090 PID file") + except OSError as e: + logger.warning(f"Failed to clear dump1090 PID file: {e}") + + +def _is_dump1090_process(pid: int) -> bool: + """Check if the given PID is actually a dump1090/readsb process.""" + try: + if platform.system() == 'Linux': + cmdline_path = Path(f'/proc/{pid}/cmdline') + if cmdline_path.exists(): + cmdline = cmdline_path.read_bytes().replace(b'\x00', b' ').decode('utf-8', errors='ignore') + return 'dump1090' in cmdline or 'readsb' in cmdline + # macOS or fallback + result = subprocess.run( + ['ps', '-p', str(pid), '-o', 'comm='], + capture_output=True, text=True, timeout=5 + ) + comm = result.stdout.strip() + return 'dump1090' in comm or 'readsb' in comm + except Exception: + return False + + +def cleanup_stale_dump1090() -> None: + """Kill a stale app-spawned dump1090 using the PID file. + + Safe no-op if no PID file exists, process is dead, or PID was reused + by another program. + """ + if not _DUMP1090_PID_FILE.exists(): + return + + try: + pid = int(_DUMP1090_PID_FILE.read_text().strip()) + except (ValueError, OSError) as e: + logger.warning(f"Invalid dump1090 PID file: {e}") + clear_dump1090_pid() + return + + # Verify this PID is still a dump1090/readsb process + if not _is_dump1090_process(pid): + logger.debug(f"PID {pid} is not dump1090/readsb (dead or reused), removing stale PID file") + clear_dump1090_pid() + return + + # Kill the process group + logger.info(f"Killing stale app-spawned dump1090 (PID {pid})") + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + # Brief wait for graceful shutdown + for _ in range(10): + try: + os.kill(pid, 0) # Check if still alive + time.sleep(0.2) + except OSError: + break + else: + # Still alive, force kill + try: + os.killpg(pgid, signal.SIGKILL) + except OSError: + pass + except OSError as e: + logger.debug(f"Error killing stale dump1090 PID {pid}: {e}") + + clear_dump1090_pid() + + def is_valid_mac(mac: str | None) -> bool: """Validate MAC address format.""" if not mac: