Harden BT Locate handoff matching and start flow

This commit is contained in:
Smittix
2026-02-20 18:57:06 +00:00
parent e386016349
commit 167f10c7f7
3 changed files with 255 additions and 113 deletions
+2 -1
View File
@@ -153,7 +153,8 @@ def get_status():
'target': None, 'target': None,
}) })
return jsonify(session.get_status()) 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']) @bt_locate_bp.route('/trail', methods=['GET'])
+107 -15
View File
@@ -43,6 +43,7 @@ const BtLocate = (function() {
let queuedDetectionOptions = null; let queuedDetectionOptions = null;
let queuedDetectionTimer = null; let queuedDetectionTimer = null;
let lastDetectionRenderAt = 0; let lastDetectionRenderAt = 0;
let startRequestInFlight = false;
const MAX_HEAT_POINTS = 1200; const MAX_HEAT_POINTS = 1200;
const MAX_TRAIL_POINTS = 1200; const MAX_TRAIL_POINTS = 1200;
@@ -72,6 +73,20 @@ const BtLocate = (function() {
1.0: '#ef4444', 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() { function getMapContainer() {
if (!map || typeof map.getContainer !== 'function') return null; if (!map || typeof map.getContainer !== 'function') return null;
@@ -90,6 +105,69 @@ const BtLocate = (function() {
return true; 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() { function init() {
modeActive = true; modeActive = true;
loadOverlayPreferences(); loadOverlayPreferences();
@@ -166,7 +244,7 @@ const BtLocate = (function() {
} }
function checkStatus() { function checkStatus() {
fetch('/bt_locate/status') fetch(statusUrl())
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.active) { if (data.active) {
@@ -191,6 +269,9 @@ const BtLocate = (function() {
} }
function start() { function start() {
if (startRequestInFlight) {
return;
}
const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value); const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value);
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
const irk = document.getElementById('btLocateIrk')?.value.trim(); const irk = document.getElementById('btLocateIrk')?.value.trim();
@@ -207,14 +288,13 @@ const BtLocate = (function() {
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi; if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
// Include user location as fallback when GPS unavailable // Include user location as fallback when GPS unavailable
const userLat = localStorage.getItem('observerLat'); const fallbackLocation = resolveFallbackLocation();
const userLon = localStorage.getItem('observerLon'); if (fallbackLocation) {
if (userLat !== null && userLon !== null) { body.fallback_lat = fallbackLocation.lat;
body.fallback_lat = parseFloat(userLat); body.fallback_lon = fallbackLocation.lon;
body.fallback_lon = parseFloat(userLon);
} }
console.log('[BtLocate] Starting with body:', body); debugLog('[BtLocate] Starting with body:', body);
if (!body.mac_address && !body.name_pattern && !body.irk_hex && if (!body.mac_address && !body.name_pattern && !body.irk_hex &&
!body.device_id && !body.device_key && !body.fingerprint_id) { !body.device_id && !body.device_key && !body.fingerprint_id) {
@@ -222,6 +302,9 @@ const BtLocate = (function() {
return; return;
} }
startRequestInFlight = true;
setStartButtonBusy(true);
fetch('/bt_locate/start', { fetch('/bt_locate/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -251,12 +334,17 @@ const BtLocate = (function() {
updateScanStatus(data.session); updateScanStatus(data.session);
// Restore any existing trail (e.g. from a stop/start cycle) // Restore any existing trail (e.g. from a stop/start cycle)
restoreTrail(); restoreTrail();
pollStatus();
} }
}) })
.catch(err => { .catch(err => {
console.error('[BtLocate] Start error:', err); console.error('[BtLocate] Start error:', err);
alert('BT Locate failed to start: ' + (err?.message || 'Unknown error')); alert('BT Locate failed to start: ' + (err?.message || 'Unknown error'));
showIdleUI(); showIdleUI();
})
.finally(() => {
startRequestInFlight = false;
setStartButtonBusy(false);
}); });
} }
@@ -278,6 +366,7 @@ const BtLocate = (function() {
} }
function showActiveUI() { function showActiveUI() {
setStartButtonBusy(false);
const startBtn = document.getElementById('btLocateStartBtn'); const startBtn = document.getElementById('btLocateStartBtn');
const stopBtn = document.getElementById('btLocateStopBtn'); const stopBtn = document.getElementById('btLocateStopBtn');
if (startBtn) startBtn.style.display = 'none'; if (startBtn) startBtn.style.display = 'none';
@@ -286,6 +375,8 @@ const BtLocate = (function() {
} }
function showIdleUI() { function showIdleUI() {
startRequestInFlight = false;
setStartButtonBusy(false);
if (queuedDetectionTimer) { if (queuedDetectionTimer) {
clearTimeout(queuedDetectionTimer); clearTimeout(queuedDetectionTimer);
queuedDetectionTimer = null; queuedDetectionTimer = null;
@@ -321,13 +412,13 @@ const BtLocate = (function() {
function connectSSE() { function connectSSE() {
if (eventSource) eventSource.close(); if (eventSource) eventSource.close();
console.log('[BtLocate] Connecting SSE stream'); debugLog('[BtLocate] Connecting SSE stream');
eventSource = new EventSource('/bt_locate/stream'); eventSource = new EventSource('/bt_locate/stream');
eventSource.addEventListener('detection', function(e) { eventSource.addEventListener('detection', function(e) {
try { try {
const event = JSON.parse(e.data); const event = JSON.parse(e.data);
console.log('[BtLocate] Detection event:', event); debugLog('[BtLocate] Detection event:', event);
handleDetection(event); handleDetection(event);
} catch (err) { } catch (err) {
console.error('[BtLocate] Parse error:', err); console.error('[BtLocate] Parse error:', err);
@@ -348,6 +439,7 @@ const BtLocate = (function() {
// Start polling fallback (catches data even if SSE fails) // Start polling fallback (catches data even if SSE fails)
startPolling(); startPolling();
pollStatus();
} }
function disconnectSSE() { function disconnectSSE() {
@@ -395,7 +487,7 @@ const BtLocate = (function() {
} }
function pollStatus() { function pollStatus() {
fetch('/bt_locate/status') fetch(statusUrl())
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (!data.active) { if (!data.active) {
@@ -1509,7 +1601,7 @@ const BtLocate = (function() {
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
showNotification(title, message); showNotification(title, message);
} else { } 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 // Resume must happen within a user gesture handler
const ctx = audioCtx; const ctx = audioCtx;
ctx.resume().then(() => { ctx.resume().then(() => {
console.log('[BtLocate] AudioContext state:', ctx.state); debugLog('[BtLocate] AudioContext state:', ctx.state);
// Confirmation beep so user knows audio is working // Confirmation beep so user knows audio is working
playTone(600, 0.08); playTone(600, 0.08);
}); });
@@ -1621,14 +1713,14 @@ const BtLocate = (function() {
btn.classList.toggle('active', btn.dataset.env === env); btn.classList.toggle('active', btn.dataset.env === env);
}); });
// Push to running session if active // Push to running session if active
fetch('/bt_locate/status').then(r => r.json()).then(data => { fetch(statusUrl()).then(r => r.json()).then(data => {
if (data.active) { if (data.active) {
fetch('/bt_locate/environment', { fetch('/bt_locate/environment', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment: env }), body: JSON.stringify({ environment: env }),
}).then(r => r.json()).then(res => { }).then(r => r.json()).then(res => {
console.log('[BtLocate] Environment updated:', res); debugLog('[BtLocate] Environment updated:', res);
}); });
} }
}).catch(() => {}); }).catch(() => {});
@@ -1645,7 +1737,7 @@ const BtLocate = (function() {
} }
function handoff(deviceInfo) { function handoff(deviceInfo) {
console.log('[BtLocate] Handoff received:', deviceInfo); debugLog('[BtLocate] Handoff received:', deviceInfo);
handoffData = deviceInfo; handoffData = deviceInfo;
// Populate fields // Populate fields
+76 -27
View File
@@ -10,7 +10,8 @@ from __future__ import annotations
import logging import logging
import queue import queue
import threading import threading
from dataclasses import dataclass import time
from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -26,6 +27,11 @@ MAX_TRAIL_POINTS = 500
# EMA smoothing factor for RSSI # EMA smoothing factor for RSSI
EMA_ALPHA = 0.3 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: def _normalize_mac(address: str | None) -> str | None:
"""Normalize MAC string to colon-separated uppercase form when possible.""" """Normalize MAC string to colon-separated uppercase form when possible."""
@@ -49,6 +55,22 @@ def _normalize_mac(address: str | None) -> str | None:
return text 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): class Environment(Enum):
"""RF propagation environment presets.""" """RF propagation environment presets."""
FREE_SPACE = 2.0 FREE_SPACE = 2.0
@@ -116,8 +138,27 @@ class LocateTarget:
known_name: str | None = None known_name: str | None = None
known_manufacturer: str | None = None known_manufacturer: str | None = None
last_known_rssi: int | 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.""" """Check if a device matches this target."""
# Match by stable device key (survives MAC randomization for many devices) # Match by stable device key (survives MAC randomization for many devices)
if self.device_key and getattr(device, 'device_key', None) == self.device_key: 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: if dev_addr and target_addr and dev_addr == target_addr:
return True 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: if self.fingerprint_id:
dev_fp = getattr(device, 'payload_fingerprint_id', None) dev_fp = getattr(device, 'payload_fingerprint_id', None)
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0 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: if dev_fp and dev_fp == self.fingerprint_id:
return True 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 # Match by RPA resolution
if self.irk_hex: if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
try: irk = irk_bytes or self._get_irk_bytes()
irk = bytes.fromhex(self.irk_hex) if irk and resolve_rpa(irk, device.address):
if len(irk) == 16 and device.address and resolve_rpa(irk, device.address): return True
return True
except (ValueError, TypeError):
pass
# Match by name pattern # Match by name pattern
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
@@ -282,6 +325,8 @@ class LocateSession:
self.callback_call_count = 0 self.callback_call_count = 0
self.poll_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 # Scanner reference
self._scanner: BluetoothScanner | None = None self._scanner: BluetoothScanner | None = None
@@ -304,6 +349,7 @@ class LocateSession:
if not self._scanner.is_scanning: if not self._scanner.is_scanning:
logger.info("BT scanner not running, starting scan for locate session") logger.info("BT scanner not running, starting scan for locate session")
self._scanner_started_by_us = True self._scanner_started_by_us = True
self._last_scan_restart_attempt = time.monotonic()
if not self._scanner.start_scan(mode='auto'): if not self._scanner.start_scan(mode='auto'):
# Surface startup failure to caller and avoid leaving stale callbacks. # Surface startup failure to caller and avoid leaving stale callbacks.
status = self._scanner.get_status() status = self._scanner.get_status()
@@ -343,7 +389,7 @@ class LocateSession:
def _poll_loop(self) -> None: def _poll_loop(self) -> None:
"""Poll scanner aggregator for target device updates.""" """Poll scanner aggregator for target device updates."""
while not self._stop_event.is_set(): while not self._stop_event.is_set():
self._stop_event.wait(timeout=1.5) self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
if self._stop_event.is_set(): if self._stop_event.is_set():
break break
try: try:
@@ -360,8 +406,11 @@ class LocateSession:
# Restart scan if it expired (bleak 10s timeout) # Restart scan if it expired (bleak 10s timeout)
if not self._scanner.is_scanning: if not self._scanner.is_scanning:
logger.info("Scanner stopped, restarting for locate session") now = time.monotonic()
self._scanner.start_scan(mode='auto') 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 # Check devices seen within a recent window. Using a short window
# (rather than the aggregator's full 120s) so that once a device # (rather than the aggregator's full 120s) so that once a device
@@ -370,7 +419,7 @@ class LocateSession:
devices = self._scanner.get_devices(max_age_seconds=15) devices = self._scanner.get_devices(max_age_seconds=15)
found_target = False found_target = False
for device in devices: for device in devices:
if not self.target.matches(device): if not self.target.matches(device, irk_bytes=self._target_irk):
continue continue
found_target = True found_target = True
rssi = device.rssi_current rssi = device.rssi_current
@@ -380,7 +429,11 @@ class LocateSession:
break # One match per poll cycle is sufficient break # One match per poll cycle is sufficient
# Log periodically for debugging # Log periodically for debugging
if self.poll_count % 20 == 0 or (self.poll_count <= 5) or not 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( logger.info(
f"Poll #{self.poll_count}: {len(devices)} devices, " f"Poll #{self.poll_count}: {len(devices)} devices, "
f"target_found={found_target}, " f"target_found={found_target}, "
@@ -396,7 +449,7 @@ class LocateSession:
self.callback_call_count += 1 self.callback_call_count += 1
self._last_seen_device = f"{device.device_id}|{device.name}" self._last_seen_device = f"{device.device_id}|{device.name}"
if not self.target.matches(device): if not self.target.matches(device, irk_bytes=self._target_irk):
return return
rssi = device.rssi_current rssi = device.rssi_current
@@ -426,12 +479,8 @@ class LocateSession:
# Check RPA resolution # Check RPA resolution
rpa_resolved = False rpa_resolved = False
if self.target.irk_hex and device.address: if self._target_irk and device.address and _address_looks_like_rpa(device.address):
try: rpa_resolved = resolve_rpa(self._target_irk, device.address)
irk = bytes.fromhex(self.target.irk_hex)
rpa_resolved = resolve_rpa(irk, device.address)
except (ValueError, TypeError):
pass
# GPS tag — prefer live GPS, fall back to user-set coordinates # GPS tag — prefer live GPS, fall back to user-set coordinates
gps_pos = get_current_position() gps_pos = get_current_position()
@@ -493,7 +542,7 @@ class LocateSession:
with self._lock: with self._lock:
return [p.to_dict() for p in self.trail if p.lat is not None] return [p.to_dict() for p in self.trail if p.lat is not None]
def get_status(self) -> dict: def get_status(self, include_debug: bool = False) -> dict:
"""Get session status.""" """Get session status."""
gps_pos = get_current_position() gps_pos = get_current_position()
@@ -501,7 +550,7 @@ class LocateSession:
# deadlock: get_status would hold self._lock then wait on # deadlock: get_status would hold self._lock then wait on
# aggregator._lock, while _poll_loop holds aggregator._lock then # aggregator._lock, while _poll_loop holds aggregator._lock then
# waits on self._lock in _record_detection. # 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_running = self._scanner.is_scanning if self._scanner else False
scanner_device_count = self._scanner.device_count if self._scanner else 0 scanner_device_count = self._scanner.device_count if self._scanner else 0
callback_registered = ( callback_registered = (
@@ -559,7 +608,7 @@ class LocateSession:
'addr': d.address, 'addr': d.address,
'name': d.name, 'name': d.name,
'rssi': d.rssi_current, 'rssi': d.rssi_current,
'match': self.target.matches(d), 'match': self.target.matches(d, irk_bytes=self._target_irk),
} }
for d in devices[:8] for d in devices[:8]
] ]