diff --git a/app.py b/app.py index e190d57..f92caea 100644 --- a/app.py +++ b/app.py @@ -661,47 +661,103 @@ def _get_subghz_active() -> bool: 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({ - 'status': 'healthy', - 'version': VERSION, - 'uptime_seconds': round(time.time() - _app_start_time, 2), - 'processes': { +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 + + +def _get_bluetooth_health() -> tuple[bool, int]: + """Return Bluetooth active state and best-effort device count.""" + legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) + scanner_running = False + scanner_count = 0 + + try: + from utils.bluetooth.scanner import _scanner_instance as bt_scanner + if bt_scanner is not None: + scanner_running = bool(bt_scanner.is_scanning) + scanner_count = int(bt_scanner.device_count) + except Exception: + scanner_running = False + scanner_count = 0 + + locate_running = False + try: + from utils.bt_locate import get_locate_session + session = get_locate_session() + if session and getattr(session, 'active', False): + scanner = getattr(session, '_scanner', None) + locate_running = bool(scanner and scanner.is_scanning) + except Exception: + locate_running = False + + return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count) + + +def _get_wifi_health() -> tuple[bool, int, int]: + """Return WiFi active state and best-effort network/client counts.""" + legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False) + scanner_running = False + scanner_networks = 0 + scanner_clients = 0 + + try: + from utils.wifi.scanner import _scanner_instance as wifi_scanner + if wifi_scanner is not None: + status = wifi_scanner.get_status() + scanner_running = bool(status.is_scanning) + scanner_networks = int(status.networks_found or 0) + scanner_clients = int(status.clients_found or 0) + except Exception: + scanner_running = False + scanner_networks = 0 + scanner_clients = 0 + + return ( + legacy_running or scanner_running, + max(len(wifi_networks), scanner_networks), + max(len(wifi_clients), scanner_clients), + ) + + +@app.route('/health') +def health_check() -> Response: + """Health check endpoint for monitoring.""" + import time + bt_active, bt_device_count = _get_bluetooth_health() + wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health() + return jsonify({ + 'status': 'healthy', + 'version': VERSION, + 'uptime_seconds': round(time.time() - _app_start_time, 2), + 'processes': { 'pager': current_process is not None and (current_process.poll() is None if current_process else False), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), - '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), - 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_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(), - }, - 'data': { - 'aircraft_count': len(adsb_aircraft), - 'vessel_count': len(ais_vessels), - 'wifi_networks_count': len(wifi_networks), - 'wifi_clients_count': len(wifi_clients), - 'bt_devices_count': len(bt_devices), - 'dsc_messages_count': len(dsc_messages), - } - }) + '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), + 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False), + 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), + 'wifi': wifi_active, + 'bluetooth': bt_active, + '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), + 'wifi_networks_count': wifi_network_count, + 'wifi_clients_count': wifi_client_count, + 'bt_devices_count': bt_device_count, + 'dsc_messages_count': len(dsc_messages), + } + }) @app.route('/killall', methods=['POST']) diff --git a/routes/bt_locate.py b/routes/bt_locate.py index eec8e13..11f1aa3 100644 --- a/routes/bt_locate.py +++ b/routes/bt_locate.py @@ -33,16 +33,18 @@ def start_session(): """ Start a locate session. - Request JSON: - - mac_address: Target MAC address (optional) - - name_pattern: Target name substring (optional) - - irk_hex: Identity Resolving Key hex string (optional) - - device_id: Device ID from Bluetooth scanner (optional) - - known_name: Hand-off device name (optional) - - known_manufacturer: Hand-off manufacturer (optional) - - last_known_rssi: Hand-off last RSSI (optional) - - environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR) - - custom_exponent: Path loss exponent for CUSTOM environment (optional) + Request JSON: + - mac_address: Target MAC address (optional) + - name_pattern: Target name substring (optional) + - irk_hex: Identity Resolving Key hex string (optional) + - device_id: Device ID from Bluetooth scanner (optional) + - device_key: Stable device key from Bluetooth scanner (optional) + - fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional) + - known_name: Hand-off device name (optional) + - known_manufacturer: Hand-off manufacturer (optional) + - last_known_rssi: Hand-off last RSSI (optional) + - environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR) + - custom_exponent: Path loss exponent for CUSTOM environment (optional) Returns: JSON with session status. @@ -50,19 +52,33 @@ def start_session(): data = request.get_json() or {} # Build target - target = LocateTarget( - mac_address=data.get('mac_address'), - name_pattern=data.get('name_pattern'), - irk_hex=data.get('irk_hex'), - device_id=data.get('device_id'), - known_name=data.get('known_name'), - known_manufacturer=data.get('known_manufacturer'), - last_known_rssi=data.get('last_known_rssi'), - ) + target = LocateTarget( + mac_address=data.get('mac_address'), + name_pattern=data.get('name_pattern'), + irk_hex=data.get('irk_hex'), + device_id=data.get('device_id'), + device_key=data.get('device_key'), + fingerprint_id=data.get('fingerprint_id'), + known_name=data.get('known_name'), + known_manufacturer=data.get('known_manufacturer'), + last_known_rssi=data.get('last_known_rssi'), + ) # At least one identifier required - if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]): - return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400 + if not any([ + target.mac_address, + target.name_pattern, + target.irk_hex, + target.device_id, + target.device_key, + target.fingerprint_id, + ]): + return jsonify({ + 'error': ( + 'At least one target identifier required ' + '(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)' + ) + }), 400 # Parse environment env_str = data.get('environment', 'OUTDOOR').upper() diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 2b64857..c6aa57b 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -1618,22 +1618,23 @@ const BluetoothMode = (function() { function doLocateHandoff(device) { console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined'); - if (typeof BtLocate !== 'undefined') { - 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, - 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 - }); - } - } + if (typeof BtLocate !== 'undefined') { + BtLocate.handoff({ + device_id: device.device_id, + device_key: device.device_key || null, + 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, + tx_power: device.tx_power || null, + appearance_name: device.appearance_name || null, + fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null, + mac_cluster_count: device.mac_cluster_count || 0 + }); + } + } // Public API return { diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 211a6b4..20cd5ae 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -144,10 +144,12 @@ const BtLocate = (function() { if (mac) body.mac_address = mac; if (namePattern) body.name_pattern = namePattern; if (irk) body.irk_hex = irk; - if (handoffData?.device_id) body.device_id = handoffData.device_id; - if (handoffData?.known_name) body.known_name = handoffData.known_name; - if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer; - if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi; + if (handoffData?.device_id) body.device_id = handoffData.device_id; + if (handoffData?.device_key) body.device_key = handoffData.device_key; + if (handoffData?.fingerprint_id) body.fingerprint_id = handoffData.fingerprint_id; + if (handoffData?.known_name) body.known_name = handoffData.known_name; + if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer; + if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi; // Include user location as fallback when GPS unavailable const userLat = localStorage.getItem('observerLat'); @@ -159,10 +161,11 @@ const BtLocate = (function() { console.log('[BtLocate] Starting with body:', body); - if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id) { - alert('Please provide at least a MAC address, name pattern, IRK, or use hand-off from Bluetooth mode.'); - return; - } + if (!body.mac_address && !body.name_pattern && !body.irk_hex && + !body.device_id && !body.device_key && !body.fingerprint_id) { + alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.'); + return; + } fetch('/bt_locate/start', { method: 'POST', @@ -248,14 +251,17 @@ const BtLocate = (function() { } }); - eventSource.addEventListener('session_ended', function() { - showIdleUI(); - disconnectSSE(); - }); - - eventSource.onerror = function() { - console.warn('[BtLocate] SSE error, polling fallback active'); - }; + eventSource.addEventListener('session_ended', function() { + showIdleUI(); + disconnectSSE(); + }); + + eventSource.onerror = function() { + console.warn('[BtLocate] SSE error, polling fallback active'); + if (eventSource && eventSource.readyState === EventSource.CLOSED) { + eventSource = null; + } + }; // Start polling fallback (catches data even if SSE fails) startPolling(); @@ -315,11 +321,16 @@ const BtLocate = (function() { return; } - updateScanStatus(data); - updateHudInfo(data); - - // Show diagnostics - const diagEl = document.getElementById('btLocateDiag'); + updateScanStatus(data); + updateHudInfo(data); + + // Recover live stream if browser closed SSE connection. + if (!eventSource || eventSource.readyState === EventSource.CLOSED) { + connectSSE(); + } + + // Show diagnostics + const diagEl = document.getElementById('btLocateDiag'); if (diagEl) { let diag = 'Polls: ' + (data.poll_count || 0) + (data.poll_thread_alive === false ? ' DEAD' : '') + diff --git a/templates/index.html b/templates/index.html index fae3770..b99ba5a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3910,7 +3910,10 @@ const btScanActive = (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) || isBtRunning; - if (btScanActive && typeof stopBtScan === 'function') stopBtScan(); + const isBtModeTransition = + (currentMode === 'bluetooth' && mode === 'bt_locate') || + (currentMode === 'bt_locate' && mode === 'bluetooth'); + if (btScanActive && !isBtModeTransition && typeof stopBtScan === 'function') stopBtScan(); if (isAprsRunning) stopAprs(); if (isTscmRunning) stopTscmSweep(); } diff --git a/utils/bt_locate.py b/utils/bt_locate.py index cf38698..2277e75 100644 --- a/utils/bt_locate.py +++ b/utils/bt_locate.py @@ -81,25 +81,31 @@ def resolve_rpa(irk: bytes, address: str) -> bool: return computed_hash == expected_hash -@dataclass -class LocateTarget: - """Target device specification for locate session.""" - mac_address: str | None = None - name_pattern: str | None = None - irk_hex: str | None = None - device_id: str | None = None - # Hand-off metadata from Bluetooth mode - known_name: str | None = None - known_manufacturer: str | None = None - last_known_rssi: int | None = None - - def matches(self, device: BTDeviceAggregate) -> bool: - """Check if a device matches this target.""" - # Match by device_id (exact) - if self.device_id and device.device_id == self.device_id: - return True - - # Match by device_id address portion (without :address_type suffix) +@dataclass +class LocateTarget: + """Target device specification for locate session.""" + mac_address: str | None = None + name_pattern: str | None = None + irk_hex: str | None = None + device_id: str | None = None + device_key: str | None = None + fingerprint_id: str | None = None + # Hand-off metadata from Bluetooth mode + known_name: str | None = None + known_manufacturer: str | None = None + last_known_rssi: int | None = None + + def matches(self, device: BTDeviceAggregate) -> bool: + """Check if a device matches this target.""" + # Match by stable device key (survives MAC randomization for many devices) + if self.device_key and getattr(device, 'device_key', None) == self.device_key: + return True + + # Match by device_id (exact) + if self.device_id and device.device_id == self.device_id: + return True + + # Match by device_id address portion (without :address_type suffix) if self.device_id and ':' in self.device_id: target_addr_part = self.device_id.rsplit(':', 1)[0].upper() dev_addr = (device.address or '').upper() @@ -107,38 +113,57 @@ class LocateTarget: return True # Match by MAC/address (case-insensitive, normalize separators) - if self.mac_address: - dev_addr = (device.address or '').upper().replace('-', ':') - target_addr = self.mac_address.upper().replace('-', ':') - if dev_addr == target_addr: - return True - - # Match by RPA resolution - if self.irk_hex: - try: - irk = bytes.fromhex(self.irk_hex) - if len(irk) == 16 and device.address and resolve_rpa(irk, device.address): + if self.mac_address: + dev_addr = (device.address or '').upper().replace('-', ':') + target_addr = self.mac_address.upper().replace('-', ':') + if dev_addr == target_addr: + return True + + # Match by payload fingerprint (guard against low-stability generic fingerprints) + if self.fingerprint_id: + dev_fp = getattr(device, 'payload_fingerprint_id', None) + dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0 + if dev_fp and dev_fp == self.fingerprint_id and dev_fp_stability >= 0.35: + return True + + # Match by RPA resolution + if self.irk_hex: + try: + irk = bytes.fromhex(self.irk_hex) + if len(irk) == 16 and device.address and resolve_rpa(irk, device.address): return True except (ValueError, TypeError): pass - # Match by name pattern - if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): - return True - - # Match by known_name from handoff (exact name match) - return bool(self.known_name and device.name and self.known_name.lower() == device.name.lower()) - - def to_dict(self) -> dict: - return { - 'mac_address': self.mac_address, - 'name_pattern': self.name_pattern, - 'irk_hex': self.irk_hex, - 'device_id': self.device_id, - 'known_name': self.known_name, - 'known_manufacturer': self.known_manufacturer, - 'last_known_rssi': self.last_known_rssi, - } + # Match by name pattern + if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): + return True + + # Match by known_name from handoff (exact or loose normalized match) + if self.known_name and device.name: + target_name = self.known_name.strip().lower() + device_name = device.name.strip().lower() + if target_name and ( + target_name == device_name + or target_name in device_name + or device_name in target_name + ): + return True + + return False + + def to_dict(self) -> dict: + return { + 'mac_address': self.mac_address, + 'name_pattern': self.name_pattern, + 'irk_hex': self.irk_hex, + 'device_id': self.device_id, + 'device_key': self.device_key, + 'fingerprint_id': self.fingerprint_id, + 'known_name': self.known_name, + 'known_manufacturer': self.known_manufacturer, + 'last_known_rssi': self.last_known_rssi, + } class DistanceEstimator: