mirror of
https://github.com/smittix/intercept.git
synced 2026-05-29 23:09:26 -07:00
Merge upstream/main and resolve adsb_dashboard.html conflict
Take upstream's crosshair animation system and updated selectAircraft(icao, source) signature. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -97,7 +97,7 @@ class AgentClient:
|
||||
except requests.RequestException as e:
|
||||
raise AgentHTTPError(f"Request failed: {e}")
|
||||
|
||||
def _post(self, path: str, data: dict | None = None) -> dict:
|
||||
def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||
"""
|
||||
Perform POST request to agent.
|
||||
|
||||
@@ -112,20 +112,21 @@ class AgentClient:
|
||||
AgentHTTPError: On HTTP errors
|
||||
AgentConnectionError: If agent is unreachable
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=data or {},
|
||||
headers=self._headers(),
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {}
|
||||
except requests.ConnectionError as e:
|
||||
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||
except requests.Timeout:
|
||||
raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s")
|
||||
url = f"{self.base_url}{path}"
|
||||
request_timeout = self.timeout if timeout is None else timeout
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=data or {},
|
||||
headers=self._headers(),
|
||||
timeout=request_timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {}
|
||||
except requests.ConnectionError as e:
|
||||
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||
except requests.Timeout:
|
||||
raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s")
|
||||
except requests.HTTPError as e:
|
||||
# Try to extract error message from response body
|
||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||
@@ -141,9 +142,9 @@ class AgentClient:
|
||||
except requests.RequestException as e:
|
||||
raise AgentHTTPError(f"Request failed: {e}")
|
||||
|
||||
def post(self, path: str, data: dict | None = None) -> dict:
|
||||
"""Public POST method for arbitrary endpoints."""
|
||||
return self._post(path, data)
|
||||
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||
"""Public POST method for arbitrary endpoints."""
|
||||
return self._post(path, data, timeout=timeout)
|
||||
|
||||
# =========================================================================
|
||||
# Capability & Status
|
||||
@@ -214,7 +215,7 @@ class AgentClient:
|
||||
"""
|
||||
return self._post(f'/{mode}/start', params or {})
|
||||
|
||||
def stop_mode(self, mode: str) -> dict:
|
||||
def stop_mode(self, mode: str, timeout: float = 8.0) -> dict:
|
||||
"""
|
||||
Stop a running mode on the agent.
|
||||
|
||||
@@ -224,7 +225,7 @@ class AgentClient:
|
||||
Returns:
|
||||
Stop result with 'status' field
|
||||
"""
|
||||
return self._post(f'/{mode}/stop')
|
||||
return self._post(f'/{mode}/stop', timeout=timeout)
|
||||
|
||||
def get_mode_status(self, mode: str) -> dict:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
class ModeActivityTracker:
|
||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
||||
|
||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
||||
self._max_buckets = max_buckets
|
||||
self._bucket_interval = bucket_interval
|
||||
self._history: dict[str, deque] = {}
|
||||
self._last_record_time = 0.0
|
||||
|
||||
def record(self) -> None:
|
||||
"""Snapshot current counts for all modes."""
|
||||
now = time.time()
|
||||
if now - self._last_record_time < self._bucket_interval:
|
||||
return
|
||||
self._last_record_time = now
|
||||
|
||||
counts = _get_mode_counts()
|
||||
for mode, count in counts.items():
|
||||
if mode not in self._history:
|
||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
||||
self._history[mode].append(count)
|
||||
|
||||
def get_sparkline(self, mode: str) -> list[int]:
|
||||
"""Return sparkline array for a mode."""
|
||||
self.record()
|
||||
return list(self._history.get(mode, []))
|
||||
|
||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
||||
"""Return sparkline arrays for all tracked modes."""
|
||||
self.record()
|
||||
return {mode: list(values) for mode, values in self._history.items()}
|
||||
|
||||
|
||||
# Singleton
|
||||
_tracker: ModeActivityTracker | None = None
|
||||
|
||||
|
||||
def get_activity_tracker() -> ModeActivityTracker:
|
||||
global _tracker
|
||||
if _tracker is None:
|
||||
_tracker = ModeActivityTracker()
|
||||
return _tracker
|
||||
|
||||
|
||||
def _safe_len(attr_name: str) -> int:
|
||||
"""Safely get len() of an app_module attribute."""
|
||||
try:
|
||||
return len(getattr(app_module, attr_name))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
||||
"""Safely read a module-level counter from a route file."""
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
return int(getattr(mod, attr_name, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _get_mode_counts() -> dict[str, int]:
|
||||
"""Read current entity counts from all available data sources."""
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
# ADS-B aircraft (DataStore)
|
||||
counts['adsb'] = _safe_len('adsb_aircraft')
|
||||
|
||||
# AIS vessels (DataStore)
|
||||
counts['ais'] = _safe_len('ais_vessels')
|
||||
|
||||
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
||||
wifi_count = 0
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
wifi_count = len(wifi_scanner.access_points)
|
||||
except Exception:
|
||||
pass
|
||||
if wifi_count == 0:
|
||||
wifi_count = _safe_len('wifi_networks')
|
||||
counts['wifi'] = wifi_count
|
||||
|
||||
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
||||
bt_count = 0
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None:
|
||||
bt_count = len(bt_scanner.get_devices())
|
||||
except Exception:
|
||||
pass
|
||||
if bt_count == 0:
|
||||
bt_count = _safe_len('bt_devices')
|
||||
counts['bluetooth'] = bt_count
|
||||
|
||||
# DSC messages (DataStore)
|
||||
counts['dsc'] = _safe_len('dsc_messages')
|
||||
|
||||
# ACARS message count (route-level counter)
|
||||
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
||||
|
||||
# VDL2 message count (route-level counter)
|
||||
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
||||
|
||||
# APRS stations (route-level dict)
|
||||
try:
|
||||
import routes.aprs as aprs_mod
|
||||
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
||||
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
|
||||
|
||||
|
||||
def get_cross_mode_summary() -> dict[str, Any]:
|
||||
"""Return counts dict for all available data sources."""
|
||||
counts = _get_mode_counts()
|
||||
wifi_clients_count = 0
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
wifi_clients_count = len(wifi_scanner.clients)
|
||||
except Exception:
|
||||
pass
|
||||
if wifi_clients_count == 0:
|
||||
wifi_clients_count = _safe_len('wifi_clients')
|
||||
counts['wifi_clients'] = wifi_clients_count
|
||||
return counts
|
||||
|
||||
|
||||
def get_mode_health() -> dict[str, dict]:
|
||||
"""Check process refs and SDR status for each mode."""
|
||||
health: dict[str, dict] = {}
|
||||
|
||||
process_map = {
|
||||
'pager': 'current_process',
|
||||
'sensor': 'sensor_process',
|
||||
'adsb': 'adsb_process',
|
||||
'ais': 'ais_process',
|
||||
'acars': 'acars_process',
|
||||
'vdl2': 'vdl2_process',
|
||||
'aprs': 'aprs_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)
|
||||
running = proc is not None and (proc.poll() is None if proc else False)
|
||||
health[mode] = {'running': running}
|
||||
|
||||
# Override WiFi/BT health with v2 scanner status if available
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
||||
health['wifi'] = {'running': True}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None and bt_scanner.is_scanning:
|
||||
health['bluetooth'] = {'running': True}
|
||||
except Exception:
|
||||
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()}
|
||||
except Exception:
|
||||
health['sdr_devices'] = {}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
EMERGENCY_SQUAWKS = {
|
||||
'7700': 'General Emergency',
|
||||
'7600': 'Comms Failure',
|
||||
'7500': 'Hijack',
|
||||
}
|
||||
|
||||
|
||||
def get_emergency_squawks() -> list[dict]:
|
||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
||||
emergencies: list[dict] = []
|
||||
try:
|
||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
||||
sq = str(aircraft.get('squawk', '')).strip()
|
||||
if sq in EMERGENCY_SQUAWKS:
|
||||
emergencies.append({
|
||||
'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq,
|
||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
||||
'altitude': aircraft.get('altitude'),
|
||||
'lat': aircraft.get('lat'),
|
||||
'lon': aircraft.get('lon'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return emergencies
|
||||
@@ -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:
|
||||
|
||||
@@ -76,6 +76,10 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
unregister_process(process)
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -112,18 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
env=env
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info from rtl_test output
|
||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=5,
|
||||
env=env
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info from rtl_test output
|
||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
||||
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
||||
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
|
||||
@@ -131,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
line = line.strip()
|
||||
match = re.match(device_pattern, line)
|
||||
if match:
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=int(match.group(1)),
|
||||
name=match.group(2).strip().rstrip(','),
|
||||
serial=match.group(3) or 'N/A',
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=int(match.group(1)),
|
||||
name=match.group(2).strip().rstrip(','),
|
||||
serial=match.group(3),
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
# Fallback: if we found devices but couldn't parse details
|
||||
if not devices:
|
||||
|
||||
@@ -122,6 +122,17 @@ class DecodeProgress:
|
||||
return result
|
||||
|
||||
|
||||
def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> list[int]:
|
||||
"""Compress recent int16 PCM samples to signed 8-bit values for SSE."""
|
||||
if raw_samples.size == 0:
|
||||
return []
|
||||
|
||||
window = raw_samples[-window_size:] if raw_samples.size > window_size else raw_samples
|
||||
packed = np.rint(window.astype(np.float64) / 256.0).astype(np.int16)
|
||||
packed = np.clip(packed, -127, 127)
|
||||
return packed.tolist()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DopplerTracker
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -423,6 +434,7 @@ class SSTVDecoder:
|
||||
# Scope: compute RMS/peak from raw int16 samples every chunk
|
||||
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
||||
peak_val = int(np.max(np.abs(raw_samples)))
|
||||
waveform = _encode_scope_waveform(raw_samples)
|
||||
|
||||
if image_decoder is not None:
|
||||
# Currently decoding an image
|
||||
@@ -451,7 +463,7 @@ class SSTVDecoder:
|
||||
message=f'Decoding {current_mode_name}: {pct}%',
|
||||
partial_image=partial_url,
|
||||
))
|
||||
self._emit_scope(rms_val, peak_val, 'decoding')
|
||||
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
|
||||
|
||||
if complete:
|
||||
# Save image
|
||||
@@ -529,7 +541,7 @@ class SSTVDecoder:
|
||||
vis_state=vis_detector.state.value,
|
||||
))
|
||||
|
||||
self._emit_scope(rms_val, peak_val, scope_tone)
|
||||
self._emit_scope(rms_val, peak_val, scope_tone, waveform)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in decode thread: {e}")
|
||||
@@ -762,11 +774,20 @@ class SSTVDecoder:
|
||||
except Exception as e:
|
||||
logger.error(f"Error in progress callback: {e}")
|
||||
|
||||
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
|
||||
def _emit_scope(
|
||||
self,
|
||||
rms: int,
|
||||
peak: int,
|
||||
tone: str | None = None,
|
||||
waveform: list[int] | None = None,
|
||||
) -> None:
|
||||
"""Emit scope signal levels to callback."""
|
||||
if self._callback:
|
||||
try:
|
||||
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
|
||||
payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}
|
||||
if waveform:
|
||||
payload['waveform'] = waveform
|
||||
self._callback(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -726,46 +726,76 @@ class UnifiedWiFiScanner:
|
||||
|
||||
return True
|
||||
|
||||
def stop_deep_scan(self) -> bool:
|
||||
"""
|
||||
Stop the deep scan.
|
||||
|
||||
Returns:
|
||||
True if scan was stopped.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._status.is_scanning:
|
||||
return True
|
||||
|
||||
# Stop deauth detector first
|
||||
self._stop_deauth_detector()
|
||||
|
||||
self._deep_scan_stop_event.set()
|
||||
|
||||
if self._deep_scan_process:
|
||||
try:
|
||||
self._deep_scan_process.terminate()
|
||||
self._deep_scan_process.wait(timeout=5)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error terminating airodump-ng: {e}")
|
||||
try:
|
||||
self._deep_scan_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._deep_scan_process = None
|
||||
|
||||
if self._deep_scan_thread:
|
||||
self._deep_scan_thread.join(timeout=5)
|
||||
self._deep_scan_thread = None
|
||||
|
||||
self._status.is_scanning = False
|
||||
|
||||
self._queue_event({
|
||||
'type': 'scan_stopped',
|
||||
'mode': SCAN_MODE_DEEP,
|
||||
})
|
||||
|
||||
return True
|
||||
def stop_deep_scan(self) -> bool:
|
||||
"""
|
||||
Stop the deep scan.
|
||||
|
||||
Returns:
|
||||
True if scan was stopped.
|
||||
"""
|
||||
cleanup_process: Optional[subprocess.Popen] = None
|
||||
cleanup_thread: Optional[threading.Thread] = None
|
||||
cleanup_detector = None
|
||||
|
||||
with self._lock:
|
||||
if not self._status.is_scanning:
|
||||
return True
|
||||
|
||||
self._deep_scan_stop_event.set()
|
||||
cleanup_process = self._deep_scan_process
|
||||
cleanup_thread = self._deep_scan_thread
|
||||
cleanup_detector = self._deauth_detector
|
||||
self._deauth_detector = None
|
||||
self._deep_scan_process = None
|
||||
self._deep_scan_thread = None
|
||||
|
||||
self._status.is_scanning = False
|
||||
self._status.error = None
|
||||
|
||||
self._queue_event({
|
||||
'type': 'scan_stopped',
|
||||
'mode': SCAN_MODE_DEEP,
|
||||
})
|
||||
|
||||
cleanup_start = time.perf_counter()
|
||||
|
||||
def _finalize_stop(
|
||||
process: Optional[subprocess.Popen],
|
||||
scan_thread: Optional[threading.Thread],
|
||||
detector,
|
||||
) -> None:
|
||||
if detector:
|
||||
try:
|
||||
detector.stop()
|
||||
logger.info("Deauth detector stopped")
|
||||
self._queue_event({'type': 'deauth_detector_stopped'})
|
||||
except Exception as exc:
|
||||
logger.error(f"Error stopping deauth detector: {exc}")
|
||||
|
||||
if process and process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=1.5)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if scan_thread and scan_thread.is_alive():
|
||||
scan_thread.join(timeout=1.5)
|
||||
|
||||
elapsed_ms = (time.perf_counter() - cleanup_start) * 1000.0
|
||||
logger.info(f"Deep scan stop finalized in {elapsed_ms:.1f}ms")
|
||||
|
||||
threading.Thread(
|
||||
target=_finalize_stop,
|
||||
args=(cleanup_process, cleanup_thread, cleanup_detector),
|
||||
daemon=True,
|
||||
name='wifi-deep-stop',
|
||||
).start()
|
||||
|
||||
return True
|
||||
|
||||
def _run_deep_scan(
|
||||
self,
|
||||
@@ -799,14 +829,32 @@ class UnifiedWiFiScanner:
|
||||
|
||||
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
self._deep_scan_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
csv_file = f"{output_prefix}-01.csv"
|
||||
process: Optional[subprocess.Popen] = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
should_track_process = False
|
||||
with self._lock:
|
||||
# Only expose the process handle if this run has not been
|
||||
# replaced by a newer deep scan session.
|
||||
if self._status.is_scanning and not self._deep_scan_stop_event.is_set():
|
||||
should_track_process = True
|
||||
self._deep_scan_process = process
|
||||
if not should_track_process:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=1.0)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
csv_file = f"{output_prefix}-01.csv"
|
||||
|
||||
# Poll CSV file for updates
|
||||
while not self._deep_scan_stop_event.is_set():
|
||||
@@ -830,14 +878,16 @@ class UnifiedWiFiScanner:
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing airodump CSV: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Deep scan error: {e}")
|
||||
self._queue_event({
|
||||
'type': 'scan_error',
|
||||
'error': str(e),
|
||||
})
|
||||
finally:
|
||||
self._deep_scan_process = None
|
||||
except Exception as e:
|
||||
logger.exception(f"Deep scan error: {e}")
|
||||
self._queue_event({
|
||||
'type': 'scan_error',
|
||||
'error': str(e),
|
||||
})
|
||||
finally:
|
||||
with self._lock:
|
||||
if process is not None and self._deep_scan_process is process:
|
||||
self._deep_scan_process = None
|
||||
|
||||
# =========================================================================
|
||||
# Observation Processing
|
||||
|
||||
Reference in New Issue
Block a user