diff --git a/routes/bt_locate.py b/routes/bt_locate.py index 85bab64..dda456d 100644 --- a/routes/bt_locate.py +++ b/routes/bt_locate.py @@ -143,17 +143,18 @@ def stop_session(): return jsonify({'status': 'stopped'}) -@bt_locate_bp.route('/status', methods=['GET']) -def get_status(): - """Get locate session status.""" - session = get_locate_session() - if not session: +@bt_locate_bp.route('/status', methods=['GET']) +def get_status(): + """Get locate session status.""" + session = get_locate_session() + if not session: return jsonify({ - 'active': False, - 'target': None, - }) - - return jsonify(session.get_status()) + 'active': False, + 'target': None, + }) + + include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes') + return jsonify(session.get_status(include_debug=include_debug)) @bt_locate_bp.route('/trail', methods=['GET']) diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 8761e82..64bad1a 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -43,6 +43,7 @@ const BtLocate = (function() { let queuedDetectionOptions = null; let queuedDetectionTimer = null; let lastDetectionRenderAt = 0; + let startRequestInFlight = false; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; @@ -72,6 +73,20 @@ const BtLocate = (function() { 1.0: '#ef4444', }, }; + const BT_LOCATE_DEBUG = (() => { + try { + const params = new URLSearchParams(window.location.search || ''); + return params.get('btlocate_debug') === '1' || + localStorage.getItem('btLocateDebug') === 'true'; + } catch (_) { + return false; + } + })(); + + function debugLog() { + if (!BT_LOCATE_DEBUG) return; + console.log.apply(console, arguments); + } function getMapContainer() { if (!map || typeof map.getContainer !== 'function') return null; @@ -90,6 +105,69 @@ const BtLocate = (function() { return true; } + function statusUrl() { + try { + const params = new URLSearchParams(window.location.search || ''); + const debugFlag = params.get('btlocate_debug') === '1' || + localStorage.getItem('btLocateDebug') === 'true'; + return debugFlag ? '/bt_locate/status?debug=1' : '/bt_locate/status'; + } catch (_) { + return '/bt_locate/status'; + } + } + + function coerceLocation(lat, lon) { + const nLat = Number(lat); + const nLon = Number(lon); + if (!isFinite(nLat) || !isFinite(nLon)) return null; + if (nLat < -90 || nLat > 90 || nLon < -180 || nLon > 180) return null; + return { lat: nLat, lon: nLon }; + } + + function resolveFallbackLocation() { + try { + if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) { + const shared = ObserverLocation.getShared(); + const normalized = coerceLocation(shared?.lat, shared?.lon); + if (normalized) return normalized; + } + } catch (_) {} + + try { + const stored = localStorage.getItem('observerLocation'); + if (stored) { + const parsed = JSON.parse(stored); + const normalized = coerceLocation(parsed?.lat, parsed?.lon); + if (normalized) return normalized; + } + } catch (_) {} + + try { + const normalized = coerceLocation( + localStorage.getItem('observerLat'), + localStorage.getItem('observerLon') + ); + if (normalized) return normalized; + } catch (_) {} + + return coerceLocation(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON); + } + + function setStartButtonBusy(busy) { + const startBtn = document.getElementById('btLocateStartBtn'); + if (!startBtn) return; + if (busy) { + if (!startBtn.dataset.defaultLabel) { + startBtn.dataset.defaultLabel = startBtn.textContent || 'Start Locate'; + } + startBtn.disabled = true; + startBtn.textContent = 'Starting...'; + return; + } + startBtn.disabled = false; + startBtn.textContent = startBtn.dataset.defaultLabel || 'Start Locate'; + } + function init() { modeActive = true; loadOverlayPreferences(); @@ -166,7 +244,7 @@ const BtLocate = (function() { } function checkStatus() { - fetch('/bt_locate/status') + fetch(statusUrl()) .then(r => r.json()) .then(data => { if (data.active) { @@ -191,6 +269,9 @@ const BtLocate = (function() { } function start() { + if (startRequestInFlight) { + return; + } const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value); const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); const irk = document.getElementById('btLocateIrk')?.value.trim(); @@ -205,23 +286,25 @@ const BtLocate = (function() { 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'); - const userLon = localStorage.getItem('observerLon'); - if (userLat !== null && userLon !== null) { - body.fallback_lat = parseFloat(userLat); - body.fallback_lon = parseFloat(userLon); + + // Include user location as fallback when GPS unavailable + const fallbackLocation = resolveFallbackLocation(); + if (fallbackLocation) { + body.fallback_lat = fallbackLocation.lat; + body.fallback_lon = fallbackLocation.lon; } - console.log('[BtLocate] Starting with body:', body); + debugLog('[BtLocate] Starting with body:', body); 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; } - + + startRequestInFlight = true; + setStartButtonBusy(true); + fetch('/bt_locate/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -251,12 +334,17 @@ const BtLocate = (function() { updateScanStatus(data.session); // Restore any existing trail (e.g. from a stop/start cycle) restoreTrail(); + pollStatus(); } }) .catch(err => { console.error('[BtLocate] Start error:', err); alert('BT Locate failed to start: ' + (err?.message || 'Unknown error')); showIdleUI(); + }) + .finally(() => { + startRequestInFlight = false; + setStartButtonBusy(false); }); } @@ -277,15 +365,18 @@ const BtLocate = (function() { .catch(err => console.error('[BtLocate] Stop error:', err)); } - function showActiveUI() { - const startBtn = document.getElementById('btLocateStartBtn'); - const stopBtn = document.getElementById('btLocateStopBtn'); - if (startBtn) startBtn.style.display = 'none'; + function showActiveUI() { + setStartButtonBusy(false); + const startBtn = document.getElementById('btLocateStartBtn'); + const stopBtn = document.getElementById('btLocateStopBtn'); + if (startBtn) startBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'inline-block'; show('btLocateHud'); } function showIdleUI() { + startRequestInFlight = false; + setStartButtonBusy(false); if (queuedDetectionTimer) { clearTimeout(queuedDetectionTimer); queuedDetectionTimer = null; @@ -321,13 +412,13 @@ const BtLocate = (function() { function connectSSE() { if (eventSource) eventSource.close(); - console.log('[BtLocate] Connecting SSE stream'); + debugLog('[BtLocate] Connecting SSE stream'); eventSource = new EventSource('/bt_locate/stream'); eventSource.addEventListener('detection', function(e) { try { const event = JSON.parse(e.data); - console.log('[BtLocate] Detection event:', event); + debugLog('[BtLocate] Detection event:', event); handleDetection(event); } catch (err) { console.error('[BtLocate] Parse error:', err); @@ -346,9 +437,10 @@ const BtLocate = (function() { } }; - // Start polling fallback (catches data even if SSE fails) - startPolling(); - } + // Start polling fallback (catches data even if SSE fails) + startPolling(); + pollStatus(); + } function disconnectSSE() { if (eventSource) { @@ -394,10 +486,10 @@ const BtLocate = (function() { if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0'); } - function pollStatus() { - fetch('/bt_locate/status') - .then(r => r.json()) - .then(data => { + function pollStatus() { + fetch(statusUrl()) + .then(r => r.json()) + .then(data => { if (!data.active) { showIdleUI(); disconnectSSE(); @@ -1509,7 +1601,7 @@ const BtLocate = (function() { if (typeof showNotification === 'function') { showNotification(title, message); } else { - console.log('[BtLocate] ' + title + ': ' + message); + debugLog('[BtLocate] ' + title + ': ' + message); } } @@ -1600,7 +1692,7 @@ const BtLocate = (function() { // Resume must happen within a user gesture handler const ctx = audioCtx; ctx.resume().then(() => { - console.log('[BtLocate] AudioContext state:', ctx.state); + debugLog('[BtLocate] AudioContext state:', ctx.state); // Confirmation beep so user knows audio is working playTone(600, 0.08); }); @@ -1621,14 +1713,14 @@ const BtLocate = (function() { btn.classList.toggle('active', btn.dataset.env === env); }); // Push to running session if active - fetch('/bt_locate/status').then(r => r.json()).then(data => { - if (data.active) { - fetch('/bt_locate/environment', { + fetch(statusUrl()).then(r => r.json()).then(data => { + if (data.active) { + fetch('/bt_locate/environment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ environment: env }), }).then(r => r.json()).then(res => { - console.log('[BtLocate] Environment updated:', res); + debugLog('[BtLocate] Environment updated:', res); }); } }).catch(() => {}); @@ -1645,7 +1737,7 @@ const BtLocate = (function() { } function handoff(deviceInfo) { - console.log('[BtLocate] Handoff received:', deviceInfo); + debugLog('[BtLocate] Handoff received:', deviceInfo); handoffData = deviceInfo; // Populate fields diff --git a/utils/bt_locate.py b/utils/bt_locate.py index c47935f..331e79d 100644 --- a/utils/bt_locate.py +++ b/utils/bt_locate.py @@ -7,12 +7,13 @@ distance estimation, and proximity alerts for search and rescue operations. from __future__ import annotations -import logging -import queue -import threading -from dataclasses import dataclass -from datetime import datetime -from enum import Enum +import logging +import queue +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum from utils.bluetooth.models import BTDeviceAggregate from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner @@ -20,12 +21,17 @@ from utils.gps import get_current_position logger = logging.getLogger('intercept.bt_locate') -# Maximum trail points to retain -MAX_TRAIL_POINTS = 500 - -# EMA smoothing factor for RSSI +# Maximum trail points to retain +MAX_TRAIL_POINTS = 500 + +# EMA smoothing factor for RSSI EMA_ALPHA = 0.3 +# Polling/restart tuning for scanner resilience without high CPU churn. +POLL_INTERVAL_SECONDS = 1.5 +SCAN_RESTART_BACKOFF_SECONDS = 8.0 +NO_MATCH_LOG_EVERY_POLLS = 10 + def _normalize_mac(address: str | None) -> str | None: """Normalize MAC string to colon-separated uppercase form when possible.""" @@ -47,6 +53,22 @@ def _normalize_mac(address: str | None) -> str | None: # Return cleaned original when not a strict MAC (caller may still use exact matching) return text + + +def _address_looks_like_rpa(address: str | None) -> bool: + """ + Return True when an address looks like a Resolvable Private Address. + + RPA check: most-significant two bits of the first octet are `01`. + """ + normalized = _normalize_mac(address) + if not normalized: + return False + try: + first_octet = int(normalized.split(':', 1)[0], 16) + except (ValueError, TypeError): + return False + return (first_octet >> 6) == 1 class Environment(Enum): @@ -116,8 +138,27 @@ class LocateTarget: known_name: str | None = None known_manufacturer: str | None = None last_known_rssi: int | None = None + _cached_irk_hex: str | None = field(default=None, init=False, repr=False) + _cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False) - def matches(self, device: BTDeviceAggregate) -> bool: + def _get_irk_bytes(self) -> bytes | None: + """Parse/cache target IRK bytes once for repeated match checks.""" + if not self.irk_hex: + return None + if self._cached_irk_hex == self.irk_hex: + return self._cached_irk_bytes + self._cached_irk_hex = self.irk_hex + self._cached_irk_bytes = None + try: + parsed = bytes.fromhex(self.irk_hex) + except (ValueError, TypeError): + return None + if len(parsed) != 16: + return None + self._cached_irk_bytes = parsed + return parsed + + def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> 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: @@ -141,21 +182,23 @@ class LocateTarget: if dev_addr and target_addr and dev_addr == target_addr: return True - # Match by payload fingerprint (guard against low-stability generic fingerprints) + # Match by payload fingerprint. + # For explicit hand-off sessions, allow exact fingerprint matches even if + # stability is still warming up. 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 + if dev_fp and dev_fp == self.fingerprint_id: + if dev_fp_stability >= 0.35: + return True + if any([self.device_id, self.device_key, self.mac_address, self.known_name]): + 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 + if self.irk_hex and device.address and _address_looks_like_rpa(device.address): + irk = irk_bytes or self._get_irk_bytes() + if irk and resolve_rpa(irk, device.address): + return True # Match by name pattern if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): @@ -257,7 +300,7 @@ class LocateSession: self.environment = environment self.fallback_lat = fallback_lat self.fallback_lon = fallback_lon - self._lock = threading.Lock() + self._lock = threading.Lock() # Distance estimator n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value @@ -281,7 +324,9 @@ class LocateSession: # Debug counters self.callback_call_count = 0 self.poll_count = 0 - self._last_seen_device: str | None = None + self._last_seen_device: str | None = None + self._last_scan_restart_attempt = 0.0 + self._target_irk = target._get_irk_bytes() # Scanner reference self._scanner: BluetoothScanner | None = None @@ -304,6 +349,7 @@ class LocateSession: if not self._scanner.is_scanning: logger.info("BT scanner not running, starting scan for locate session") self._scanner_started_by_us = True + self._last_scan_restart_attempt = time.monotonic() if not self._scanner.start_scan(mode='auto'): # Surface startup failure to caller and avoid leaving stale callbacks. status = self._scanner.get_status() @@ -342,37 +388,40 @@ class LocateSession: def _poll_loop(self) -> None: """Poll scanner aggregator for target device updates.""" - while not self._stop_event.is_set(): - self._stop_event.wait(timeout=1.5) - if self._stop_event.is_set(): - break - try: - self._check_aggregator() - except Exception as e: - logger.error(f"Locate poll error: {e}") + while not self._stop_event.is_set(): + self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS) + if self._stop_event.is_set(): + break + try: + self._check_aggregator() + except Exception as e: + logger.error(f"Locate poll error: {e}") def _check_aggregator(self) -> None: """Check the scanner's aggregator for the target device.""" if not self._scanner: return - self.poll_count += 1 - - # Restart scan if it expired (bleak 10s timeout) - if not self._scanner.is_scanning: - logger.info("Scanner stopped, restarting for locate session") - self._scanner.start_scan(mode='auto') - - # Check devices seen within a recent window. Using a short window + self.poll_count += 1 + + # Restart scan if it expired (bleak 10s timeout) + if not self._scanner.is_scanning: + now = time.monotonic() + if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS: + self._last_scan_restart_attempt = now + logger.info("Scanner stopped, restarting for locate session") + self._scanner.start_scan(mode='auto') + + # Check devices seen within a recent window. Using a short window # (rather than the aggregator's full 120s) so that once a device # goes silent its stale RSSI stops producing detections. The window # must survive bleak's 10s scan cycle + restart gap (~3s). devices = self._scanner.get_devices(max_age_seconds=15) - found_target = False - for device in devices: - if not self.target.matches(device): - continue - found_target = True + found_target = False + for device in devices: + if not self.target.matches(device, irk_bytes=self._target_irk): + continue + found_target = True rssi = device.rssi_current if rssi is None: continue @@ -380,10 +429,14 @@ class LocateSession: break # One match per poll cycle is sufficient # Log periodically for debugging - if self.poll_count % 20 == 0 or (self.poll_count <= 5) or not found_target: - logger.info( - f"Poll #{self.poll_count}: {len(devices)} devices, " - f"target_found={found_target}, " + if ( + self.poll_count <= 5 + or self.poll_count % 20 == 0 + or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0) + ): + logger.info( + f"Poll #{self.poll_count}: {len(devices)} devices, " + f"target_found={found_target}, " f"detections={self.detection_count}, " f"scanning={self._scanner.is_scanning}" ) @@ -396,8 +449,8 @@ class LocateSession: self.callback_call_count += 1 self._last_seen_device = f"{device.device_id}|{device.name}" - if not self.target.matches(device): - return + if not self.target.matches(device, irk_bytes=self._target_irk): + return rssi = device.rssi_current if rssi is None: @@ -425,13 +478,9 @@ class LocateSession: band = DistanceEstimator.proximity_band(distance) # Check RPA resolution - rpa_resolved = False - if self.target.irk_hex and device.address: - try: - irk = bytes.fromhex(self.target.irk_hex) - rpa_resolved = resolve_rpa(irk, device.address) - except (ValueError, TypeError): - pass + rpa_resolved = False + if self._target_irk and device.address and _address_looks_like_rpa(device.address): + rpa_resolved = resolve_rpa(self._target_irk, device.address) # GPS tag — prefer live GPS, fall back to user-set coordinates gps_pos = get_current_position() @@ -493,15 +542,15 @@ class LocateSession: with self._lock: return [p.to_dict() for p in self.trail if p.lat is not None] - def get_status(self) -> dict: - """Get session status.""" - gps_pos = get_current_position() + def get_status(self, include_debug: bool = False) -> dict: + """Get session status.""" + gps_pos = get_current_position() # Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA # deadlock: get_status would hold self._lock then wait on # aggregator._lock, while _poll_loop holds aggregator._lock then # waits on self._lock in _record_detection. - debug_devices = self._debug_device_sample() + debug_devices = self._debug_device_sample() if include_debug else [] scanner_running = self._scanner.is_scanning if self._scanner else False scanner_device_count = self._scanner.device_count if self._scanner else 0 callback_registered = ( @@ -537,8 +586,8 @@ class LocateSession: 'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None, 'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None, 'latest_band': self.trail[-1].proximity_band if self.trail else None, - 'debug_devices': debug_devices, - } + 'debug_devices': debug_devices, + } def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None: """Update the environment and recalculate distance estimator.""" @@ -553,16 +602,16 @@ class LocateSession: return [] try: devices = self._scanner.get_devices(max_age_seconds=30) - return [ - { - 'id': d.device_id, - 'addr': d.address, - 'name': d.name, - 'rssi': d.rssi_current, - 'match': self.target.matches(d), - } - for d in devices[:8] - ] + return [ + { + 'id': d.device_id, + 'addr': d.address, + 'name': d.name, + 'rssi': d.rssi_current, + 'match': self.target.matches(d, irk_bytes=self._target_irk), + } + for d in devices[:8] + ] except Exception: return []