mirror of
https://github.com/smittix/intercept.git
synced 2026-07-29 19:18:10 -07:00
Harden BT Locate handoff matching and start flow
This commit is contained in:
+11
-10
@@ -143,17 +143,18 @@ def stop_session():
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@bt_locate_bp.route('/status', methods=['GET'])
|
@bt_locate_bp.route('/status', methods=['GET'])
|
||||||
def get_status():
|
def get_status():
|
||||||
"""Get locate session status."""
|
"""Get locate session status."""
|
||||||
session = get_locate_session()
|
session = get_locate_session()
|
||||||
if not session:
|
if not session:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'active': False,
|
'active': False,
|
||||||
'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'])
|
||||||
|
|||||||
+122
-30
@@ -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();
|
||||||
@@ -205,23 +286,25 @@ const BtLocate = (function() {
|
|||||||
if (handoffData?.known_name) body.known_name = handoffData.known_name;
|
if (handoffData?.known_name) body.known_name = handoffData.known_name;
|
||||||
if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer;
|
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?.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) {
|
||||||
alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.');
|
alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.');
|
||||||
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,15 +365,18 @@ const BtLocate = (function() {
|
|||||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
function showActiveUI() {
|
function showActiveUI() {
|
||||||
const startBtn = document.getElementById('btLocateStartBtn');
|
setStartButtonBusy(false);
|
||||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
const startBtn = document.getElementById('btLocateStartBtn');
|
||||||
if (startBtn) startBtn.style.display = 'none';
|
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||||
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
if (stopBtn) stopBtn.style.display = 'inline-block';
|
if (stopBtn) stopBtn.style.display = 'inline-block';
|
||||||
show('btLocateHud');
|
show('btLocateHud');
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -346,9 +437,10 @@ 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() {
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
@@ -394,10 +486,10 @@ const BtLocate = (function() {
|
|||||||
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
|
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
showIdleUI();
|
showIdleUI();
|
||||||
disconnectSSE();
|
disconnectSSE();
|
||||||
@@ -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
|
||||||
|
|||||||
+122
-73
@@ -7,12 +7,13 @@ distance estimation, and proximity alerts for search and rescue operations.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
import time
|
||||||
from datetime import datetime
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from utils.bluetooth.models import BTDeviceAggregate
|
from utils.bluetooth.models import BTDeviceAggregate
|
||||||
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
|
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')
|
logger = logging.getLogger('intercept.bt_locate')
|
||||||
|
|
||||||
# Maximum trail points to retain
|
# Maximum trail points to retain
|
||||||
MAX_TRAIL_POINTS = 500
|
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."""
|
||||||
@@ -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 cleaned original when not a strict MAC (caller may still use exact matching)
|
||||||
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):
|
||||||
@@ -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():
|
||||||
@@ -257,7 +300,7 @@ class LocateSession:
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.fallback_lat = fallback_lat
|
self.fallback_lat = fallback_lat
|
||||||
self.fallback_lon = fallback_lon
|
self.fallback_lon = fallback_lon
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# Distance estimator
|
# Distance estimator
|
||||||
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
|
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
|
||||||
@@ -281,7 +324,9 @@ class LocateSession:
|
|||||||
# Debug counters
|
# Debug counters
|
||||||
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()
|
||||||
@@ -342,37 +388,40 @@ 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:
|
||||||
self._check_aggregator()
|
self._check_aggregator()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Locate poll error: {e}")
|
logger.error(f"Locate poll error: {e}")
|
||||||
|
|
||||||
def _check_aggregator(self) -> None:
|
def _check_aggregator(self) -> None:
|
||||||
"""Check the scanner's aggregator for the target device."""
|
"""Check the scanner's aggregator for the target device."""
|
||||||
if not self._scanner:
|
if not self._scanner:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.poll_count += 1
|
self.poll_count += 1
|
||||||
|
|
||||||
# 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
|
||||||
# Check devices seen within a recent window. Using a short window
|
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
|
# (rather than the aggregator's full 120s) so that once a device
|
||||||
# goes silent its stale RSSI stops producing detections. The window
|
# goes silent its stale RSSI stops producing detections. The window
|
||||||
# must survive bleak's 10s scan cycle + restart gap (~3s).
|
# must survive bleak's 10s scan cycle + restart gap (~3s).
|
||||||
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
|
||||||
if rssi is None:
|
if rssi is None:
|
||||||
continue
|
continue
|
||||||
@@ -380,10 +429,14 @@ 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 (
|
||||||
logger.info(
|
self.poll_count <= 5
|
||||||
f"Poll #{self.poll_count}: {len(devices)} devices, "
|
or self.poll_count % 20 == 0
|
||||||
f"target_found={found_target}, "
|
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"detections={self.detection_count}, "
|
||||||
f"scanning={self._scanner.is_scanning}"
|
f"scanning={self._scanner.is_scanning}"
|
||||||
)
|
)
|
||||||
@@ -396,8 +449,8 @@ 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
|
||||||
if rssi is None:
|
if rssi is None:
|
||||||
@@ -425,13 +478,9 @@ class LocateSession:
|
|||||||
band = DistanceEstimator.proximity_band(distance)
|
band = DistanceEstimator.proximity_band(distance)
|
||||||
|
|
||||||
# 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,15 +542,15 @@ 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()
|
||||||
|
|
||||||
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
|
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
|
||||||
# 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 = (
|
||||||
@@ -537,8 +586,8 @@ class LocateSession:
|
|||||||
'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None,
|
'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_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,
|
'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:
|
def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None:
|
||||||
"""Update the environment and recalculate distance estimator."""
|
"""Update the environment and recalculate distance estimator."""
|
||||||
@@ -553,16 +602,16 @@ class LocateSession:
|
|||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
devices = self._scanner.get_devices(max_age_seconds=30)
|
devices = self._scanner.get_devices(max_age_seconds=30)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'id': d.device_id,
|
'id': d.device_id,
|
||||||
'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]
|
||||||
]
|
]
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user