Merge branch 'smittix:main' into main

This commit is contained in:
Mitch Ross
2026-02-21 12:12:54 -05:00
committed by GitHub
30 changed files with 1034 additions and 2795 deletions
+23 -24
View File
@@ -122,14 +122,14 @@ def _get_mode_counts() -> dict[str, int]:
except Exception:
counts['aprs'] = 0
# Meshtastic recent messages (route-level list)
try:
import routes.meshtastic as mesh_route
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
except Exception:
counts['meshtastic'] = 0
return counts
# Meshtastic recent messages (route-level list)
try:
import routes.meshtastic as mesh_route
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
except Exception:
counts['meshtastic'] = 0
return counts
def get_cross_mode_summary() -> dict[str, Any]:
@@ -160,12 +160,11 @@ def get_mode_health() -> dict[str, dict]:
'acars': 'acars_process',
'vdl2': 'vdl2_process',
'aprs': 'aprs_process',
'wifi': 'wifi_process',
'bluetooth': 'bt_process',
'dsc': 'dsc_process',
'rtlamr': 'rtlamr_process',
'dmr': 'dmr_process',
}
'wifi': 'wifi_process',
'bluetooth': 'bt_process',
'dsc': 'dsc_process',
'rtlamr': 'rtlamr_process',
}
for mode, attr in process_map.items():
proc = getattr(app_module, attr, None)
@@ -187,16 +186,16 @@ def get_mode_health() -> dict[str, dict]:
pass
# Meshtastic: check client connection status
try:
from utils.meshtastic import get_meshtastic_client
client = get_meshtastic_client()
health['meshtastic'] = {'running': client._interface is not None}
except Exception:
health['meshtastic'] = {'running': False}
try:
sdr_status = app_module.get_sdr_device_status()
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
try:
from utils.meshtastic import get_meshtastic_client
client = get_meshtastic_client()
health['meshtastic'] = {'running': client._interface is not None}
except Exception:
health['meshtastic'] = {'running': False}
try:
sdr_status = app_module.get_sdr_device_status()
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
except Exception:
health['sdr_devices'] = {}
+193 -114
View File
@@ -7,24 +7,68 @@ 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
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
# 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):
@@ -94,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:
@@ -112,28 +175,30 @@ class LocateTarget:
if target_addr_part and dev_addr == target_addr_part:
return True
# Match by MAC/address (case-insensitive, normalize separators)
# Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address:
dev_addr = (device.address or '').upper().replace('-', ':')
target_addr = self.mac_address.upper().replace('-', ':')
if dev_addr == target_addr:
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 (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():
@@ -235,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
@@ -259,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
@@ -268,27 +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)
# 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
if not self._scanner.start_scan(mode='auto'):
logger.warning("Failed to start BT scanner for locate session")
else:
self._scanner_started_by_us = 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(
@@ -314,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
@@ -352,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}"
)
@@ -368,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:
@@ -397,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()
@@ -465,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 = (
@@ -509,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."""
@@ -525,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 []
@@ -550,25 +627,27 @@ _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
)
_session.start()
return _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
def stop_locate_session() -> None:
+17 -16
View File
@@ -550,12 +550,12 @@ def init_db() -> None:
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1)
''')
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
''')
logger.info("Database initialized successfully")
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
''')
logger.info("Database initialized successfully")
def close_db() -> None:
@@ -2285,10 +2285,10 @@ def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
return cursor.rowcount > 0
def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
"""Delete a tracked satellite by NORAD ID. Refuses to delete builtins."""
with get_db() as conn:
row = conn.execute(
def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
"""Delete a tracked satellite by NORAD ID. Refuses to delete builtins."""
with get_db() as conn:
row = conn.execute(
'SELECT builtin FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
).fetchone()
@@ -2296,9 +2296,10 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
return False, 'Satellite not found'
if row[0]:
return False, 'Cannot remove builtin satellite'
conn.execute(
'DELETE FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
)
return True, 'Removed'
conn.execute(
'DELETE FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
)
return True, 'Removed'
-1
View File
@@ -54,7 +54,6 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
# Alert failures should never break streaming
pass
def _extract_device_id(event: dict) -> str | None:
for field in DEVICE_ID_FIELDS:
value = event.get(field)
+4 -20
View File
@@ -343,26 +343,10 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [
regions=["GLOBAL"],
),
# LoRaWAN
SignalTypeDefinition(
label="LoRaWAN / LoRa Device",
tags=["iot", "lora", "lpwan", "telemetry"],
description="LoRa long-range IoT device",
frequency_ranges=[
(863_000_000, 870_000_000), # EU868
(902_000_000, 928_000_000), # US915
],
modulation_hints=["LoRa", "CSS", "FSK"],
bandwidth_range=(125_000, 500_000), # LoRa spreading bandwidths
base_score=11,
is_burst_type=True,
regions=["UK/EU", "US"],
),
# Key Fob / Remote
SignalTypeDefinition(
label="Remote Control / Key Fob",
tags=["remote", "keyfob", "automotive", "burst", "ism"],
# Key Fob / Remote
SignalTypeDefinition(
label="Remote Control / Key Fob",
tags=["remote", "keyfob", "automotive", "burst", "ism"],
description="Wireless remote control or vehicle key fob",
frequency_ranges=[
(314_900_000, 315_100_000), # 315 MHz (US)