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

View File

@@ -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'])

View File

@@ -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

View File

@@ -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 []