Release v2.22.0

Waterfall overhaul, new modes (fingerprint, RF heatmap, SignalID, voice
alerts), PWA support, mode stop responsiveness improvements, ADS-B MSG2
surface tracking, WebSDR overhaul, and full documentation audit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-23 19:31:10 +00:00
parent 3acdab816a
commit 9705e58691
15 changed files with 647 additions and 448 deletions
+6
View File
@@ -55,6 +55,12 @@ def _load_meta() -> dict[str, Any] | None:
if os.path.exists(DB_META_FILE):
with open(DB_META_FILE, 'r') as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Corrupt aircraft db meta file, removing: {e}")
try:
os.remove(DB_META_FILE)
except OSError:
pass
except Exception as e:
logger.warning(f"Error loading aircraft db meta: {e}")
return None
+275 -257
View File
@@ -7,68 +7,68 @@ distance estimation, and proximity alerts for search and rescue operations.
from __future__ import annotations
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
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
from utils.gps import get_current_position
logger = logging.getLogger('intercept.bt_locate')
logger = logging.getLogger('intercept.bt_locate')
# 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."""
if not address:
return None
text = str(address).strip().upper().replace('-', ':')
if not text:
return None
# Handle raw 12-hex form: AABBCCDDEEFF
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
if ':' not in text and len(raw) == 12:
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
parts = text.split(':')
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts):
return ':'.join(parts)
# 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
# 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."""
if not address:
return None
text = str(address).strip().upper().replace('-', ':')
if not text:
return None
# Handle raw 12-hex form: AABBCCDDEEFF
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
if ':' not in text and len(raw) == 12:
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
parts = text.split(':')
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts):
return ':'.join(parts)
# 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):
@@ -125,110 +125,110 @@ def resolve_rpa(irk: bytes, address: str) -> bool:
return computed_hash == expected_hash
@dataclass
class LocateTarget:
"""Target device specification for locate session."""
mac_address: str | None = None
name_pattern: str | None = None
irk_hex: str | None = None
device_id: str | None = None
device_key: str | None = None
fingerprint_id: str | None = None
# Hand-off metadata from Bluetooth mode
known_name: str | None = None
known_manufacturer: str | None = None
last_known_rssi: int | None = None
_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 _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:
return True
# Match by device_id (exact)
if self.device_id and device.device_id == self.device_id:
return True
# Match by device_id address portion (without :address_type suffix)
@dataclass
class LocateTarget:
"""Target device specification for locate session."""
mac_address: str | None = None
name_pattern: str | None = None
irk_hex: str | None = None
device_id: str | None = None
device_key: str | None = None
fingerprint_id: str | None = None
# Hand-off metadata from Bluetooth mode
known_name: str | None = None
known_manufacturer: str | None = None
last_known_rssi: int | None = None
_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 _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:
return True
# Match by device_id (exact)
if self.device_id and device.device_id == self.device_id:
return True
# Match by device_id address portion (without :address_type suffix)
if self.device_id and ':' in self.device_id:
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
dev_addr = (device.address or '').upper()
if target_addr_part and dev_addr == target_addr_part:
return True
# Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address:
dev_addr = _normalize_mac(device.address)
target_addr = _normalize_mac(self.mac_address)
if dev_addr and target_addr and dev_addr == target_addr:
return True
# 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:
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 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 MAC/address (case-insensitive, normalize separators)
if self.mac_address:
dev_addr = _normalize_mac(device.address)
target_addr = _normalize_mac(self.mac_address)
if dev_addr and target_addr and dev_addr == target_addr:
return True
# Match by name pattern
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
return True
# Match by known_name from handoff (exact or loose normalized match)
if self.known_name and device.name:
target_name = self.known_name.strip().lower()
device_name = device.name.strip().lower()
if target_name and (
target_name == device_name
or target_name in device_name
or device_name in target_name
):
return True
return False
def to_dict(self) -> dict:
return {
'mac_address': self.mac_address,
'name_pattern': self.name_pattern,
'irk_hex': self.irk_hex,
'device_id': self.device_id,
'device_key': self.device_key,
'fingerprint_id': self.fingerprint_id,
'known_name': self.known_name,
'known_manufacturer': self.known_manufacturer,
'last_known_rssi': self.last_known_rssi,
}
# 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:
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 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():
return True
# Match by known_name from handoff (exact or loose normalized match)
if self.known_name and device.name:
target_name = self.known_name.strip().lower()
device_name = device.name.strip().lower()
if target_name and (
target_name == device_name
or target_name in device_name
or device_name in target_name
):
return True
return False
def to_dict(self) -> dict:
return {
'mac_address': self.mac_address,
'name_pattern': self.name_pattern,
'irk_hex': self.irk_hex,
'device_id': self.device_id,
'device_key': self.device_key,
'fingerprint_id': self.fingerprint_id,
'known_name': self.known_name,
'known_manufacturer': self.known_manufacturer,
'last_known_rssi': self.last_known_rssi,
}
class DistanceEstimator:
@@ -300,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
@@ -324,9 +324,9 @@ class LocateSession:
# Debug counters
self.callback_call_count = 0
self.poll_count = 0
self._last_seen_device: str | None = None
self._last_scan_restart_attempt = 0.0
self._target_irk = target._get_irk_bytes()
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
@@ -335,34 +335,34 @@ class LocateSession:
# Track last RSSI per device to detect changes
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
def start(self) -> bool:
"""Start the locate session.
Subscribes to scanner callbacks AND runs a polling thread that
checks the aggregator directly (handles bleak scan timeout).
"""
self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device)
self._scanner_started_by_us = False
# Ensure BLE scanning is active
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()
reason = status.error or "unknown error"
logger.warning(f"Failed to start BT scanner for locate session: {reason}")
self._scanner.remove_device_callback(self._on_device)
self._scanner = None
self._scanner_started_by_us = False
return False
self.active = True
self.started_at = datetime.now()
self._stop_event.clear()
def start(self) -> bool:
"""Start the locate session.
Subscribes to scanner callbacks AND runs a polling thread that
checks the aggregator directly (handles bleak scan timeout).
"""
self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device)
self._scanner_started_by_us = False
# Ensure BLE scanning is active
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()
reason = status.error or "unknown error"
logger.warning(f"Failed to start BT scanner for locate session: {reason}")
self._scanner.remove_device_callback(self._on_device)
self._scanner = None
self._scanner_started_by_us = False
return False
self.active = True
self.started_at = datetime.now()
self._stop_event.clear()
# Start polling thread as reliable fallback
self._poll_thread = threading.Thread(
@@ -388,40 +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=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}")
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:
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
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, irk_bytes=self._target_irk):
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
@@ -429,14 +429,14 @@ class LocateSession:
break # One match per poll cycle is sufficient
# Log periodically for debugging
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}, "
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}"
)
@@ -449,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, irk_bytes=self._target_irk):
return
if not self.target.matches(device, irk_bytes=self._target_irk):
return
rssi = device.rssi_current
if rssi is None:
@@ -478,9 +478,9 @@ class LocateSession:
band = DistanceEstimator.proximity_band(distance)
# Check RPA resolution
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)
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()
@@ -542,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, include_debug: bool = False) -> 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() if include_debug else []
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 = (
@@ -586,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."""
@@ -602,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, irk_bytes=self._target_irk),
}
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 []
@@ -627,37 +627,55 @@ _session: LocateSession | None = None
_session_lock = threading.Lock()
def start_locate_session(
target: LocateTarget,
environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None,
fallback_lat: float | None = None,
def start_locate_session(
target: LocateTarget,
environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None,
fallback_lat: float | None = None,
fallback_lon: float | None = None,
) -> LocateSession:
"""Start a new locate session, stopping any existing one."""
global _session
with _session_lock:
if _session and _session.active:
_session.stop()
_session = LocateSession(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
if not _session.start():
_session = None
raise RuntimeError("Bluetooth scanner failed to start")
return _session
# Grab and evict any existing session without holding the lock during stop()
# (stop() joins a thread which can block for up to 3 s).
old_session = None
with _session_lock:
if _session and _session.active:
old_session = _session
_session = None
if old_session:
old_session.stop()
new_session = LocateSession(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
with _session_lock:
_session = new_session
if not new_session.start():
with _session_lock:
if _session is new_session:
_session = None
raise RuntimeError("Bluetooth scanner failed to start")
return new_session
def stop_locate_session() -> None:
"""Stop the active locate session."""
global _session
# Release the lock before stop() so concurrent status/SSE requests
# aren't blocked for up to 3 s while the poll thread is joined.
session_to_stop = None
with _session_lock:
if _session:
_session.stop()
_session = None
session_to_stop = _session
_session = None
if session_to_stop:
session_to_stop.stop()
def get_locate_session() -> LocateSession | None: