v2.26.0: fix SSE fanout crash and branded logo FOUC

- Fix SSE fanout thread AttributeError when source queue is None during
  interpreter shutdown by snapshotting to local variable with null guard
- Fix branded "i" logo rendering oversized on first page load (FOUC) by
  adding inline width/height to SVG elements across 10 templates
- Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-13 11:51:27 +00:00
parent 00362bcd57
commit e00fbfddc1
183 changed files with 2006 additions and 4243 deletions
+29 -29
View File
@@ -1,43 +1,43 @@
# Utility modules for INTERCEPT
from .dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from .process import (
cleanup_stale_processes,
is_valid_mac,
is_valid_channel,
detect_devices,
safe_terminate,
register_process,
unregister_process,
cleanup_all_processes,
)
from .cleanup import CleanupManager, DataStore, cleanup_dict, cleanup_manager
from .dependencies import TOOL_DEPENDENCIES, check_all_dependencies, check_tool
from .logging import (
get_logger,
adsb_logger,
app_logger,
bluetooth_logger,
get_logger,
pager_logger,
satellite_logger,
sensor_logger,
wifi_logger,
bluetooth_logger,
adsb_logger,
satellite_logger,
)
from .process import (
cleanup_all_processes,
cleanup_stale_processes,
detect_devices,
is_valid_channel,
is_valid_mac,
register_process,
safe_terminate,
unregister_process,
)
from .sse import clear_queue, format_sse, sse_stream
from .validation import (
escape_html,
sanitize_callsign,
sanitize_device_name,
sanitize_ssid,
validate_device_index,
validate_elevation,
validate_frequency,
validate_gain,
validate_hours,
validate_latitude,
validate_longitude,
validate_frequency,
validate_device_index,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
validate_gain,
validate_ppm,
validate_hours,
validate_elevation,
validate_wifi_channel,
validate_mac_address,
validate_positive_int,
sanitize_callsign,
sanitize_ssid,
sanitize_device_name,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
validate_wifi_channel,
)
from .sse import sse_stream, format_sse, clear_queue
from .cleanup import DataStore, CleanupManager, cleanup_manager, cleanup_dict
+6 -8
View File
@@ -6,13 +6,13 @@ import logging
import queue
import threading
import time
from collections.abc import Iterable
from datetime import datetime, timezone
from typing import Iterable
# psycopg2 is optional - only needed for PostgreSQL history persistence
try:
import psycopg2
from psycopg2.extras import execute_values, Json
from psycopg2.extras import Json, execute_values
PSYCOPG2_AVAILABLE = True
except ImportError:
psycopg2 = None # type: ignore
@@ -20,6 +20,8 @@ except ImportError:
Json = None # type: ignore
PSYCOPG2_AVAILABLE = False
import contextlib
from config import (
ADSB_DB_HOST,
ADSB_DB_NAME,
@@ -289,10 +291,8 @@ class AdsbHistoryWriter:
return True
except Exception as exc:
logger.warning("ADS-B history insert failed: %s", exc)
try:
with contextlib.suppress(Exception):
conn.rollback()
except Exception:
pass
self._conn = None
time.sleep(2.0)
return False
@@ -393,10 +393,8 @@ class AdsbSnapshotWriter:
return True
except Exception as exc:
logger.warning("ADS-B snapshot insert failed: %s", exc)
try:
with contextlib.suppress(Exception):
conn.rollback()
except Exception:
pass
self._conn = None
time.sleep(2.0)
return False
-1
View File
@@ -5,7 +5,6 @@ HTTP client for communicating with remote Intercept agents.
from __future__ import annotations
import logging
from typing import Any
import requests
+5 -7
View File
@@ -2,15 +2,15 @@
from __future__ import annotations
import contextlib
import json
import logging
import os
import threading
import time
from datetime import datetime
from typing import Any
from urllib.request import urlopen, Request
from urllib.error import URLError
from urllib.request import Request, urlopen
logger = logging.getLogger('intercept.aircraft_db')
@@ -53,14 +53,12 @@ def _load_meta() -> dict[str, Any] | None:
"""Load database metadata."""
try:
if os.path.exists(DB_META_FILE):
with open(DB_META_FILE, 'r') as f:
with open(DB_META_FILE) as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Corrupt aircraft db meta file, removing: {e}")
try:
with contextlib.suppress(OSError):
os.remove(DB_META_FILE)
except OSError:
pass
except Exception as e:
logger.warning(f"Error loading aircraft db meta: {e}")
return None
@@ -89,7 +87,7 @@ def load_database() -> bool:
try:
with _cache_lock:
with open(DB_FILE, 'r') as f:
with open(DB_FILE) as f:
data = json.load(f)
_aircraft_cache = data.get('aircraft', {})
+3 -2
View File
@@ -8,11 +8,12 @@ import queue
import re
import threading
import time
from collections.abc import Generator
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Generator
from typing import Any
from config import ALERT_WEBHOOK_URL, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_SECRET
from config import ALERT_WEBHOOK_SECRET, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_URL
from utils.database import get_db
logger = logging.getLogger('intercept.alerts')
+22 -22
View File
@@ -8,40 +8,40 @@ device aggregation, RSSI statistics, and observable heuristics.
from .aggregator import DeviceAggregator
from .capability_check import check_capabilities, quick_adapter_check
from .constants import (
# Range bands (legacy)
RANGE_VERY_CLOSE,
RANGE_CLOSE,
RANGE_NEARBY,
RANGE_FAR,
RANGE_UNKNOWN,
# Proximity bands (new)
PROXIMITY_IMMEDIATE,
PROXIMITY_NEAR,
PROXIMITY_FAR,
PROXIMITY_UNKNOWN,
# Protocols
PROTOCOL_BLE,
PROTOCOL_CLASSIC,
PROTOCOL_AUTO,
ADDRESS_TYPE_NRPA,
# Address types
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_RPA,
ADDRESS_TYPE_NRPA,
PROTOCOL_AUTO,
# Protocols
PROTOCOL_BLE,
PROTOCOL_CLASSIC,
PROXIMITY_FAR,
# Proximity bands (new)
PROXIMITY_IMMEDIATE,
PROXIMITY_NEAR,
PROXIMITY_UNKNOWN,
RANGE_CLOSE,
RANGE_FAR,
RANGE_NEARBY,
RANGE_UNKNOWN,
# Range bands (legacy)
RANGE_VERY_CLOSE,
)
from .device_key import generate_device_key, is_randomized_mac, extract_key_type
from .device_key import extract_key_type, generate_device_key, is_randomized_mac
from .distance import DistanceEstimator, ProximityBand, get_distance_estimator
from .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_all_devices
from .heuristics import HeuristicsEngine, evaluate_all_devices, evaluate_device_heuristics
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
from .ring_buffer import RingBuffer, get_ring_buffer, reset_ring_buffer
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
from .tracker_signatures import (
TrackerSignatureEngine,
TrackerDetectionResult,
TrackerType,
TrackerConfidence,
DeviceFingerprint,
TrackerConfidence,
TrackerDetectionResult,
TrackerSignatureEngine,
TrackerType,
detect_tracker,
get_tracker_engine,
)
+23 -27
View File
@@ -9,40 +9,37 @@ from __future__ import annotations
import statistics
import threading
from datetime import datetime, timedelta
from typing import Optional
from .constants import (
MAX_RSSI_SAMPLES,
DEVICE_STALE_TIMEOUT,
RSSI_VERY_CLOSE,
RSSI_CLOSE,
RSSI_NEARBY,
RSSI_FAR,
CONFIDENCE_VERY_CLOSE,
CONFIDENCE_CLOSE,
CONFIDENCE_NEARBY,
CONFIDENCE_FAR,
RANGE_VERY_CLOSE,
RANGE_CLOSE,
RANGE_NEARBY,
RANGE_FAR,
RANGE_UNKNOWN,
ADDRESS_TYPE_NRPA,
ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_RPA,
ADDRESS_TYPE_NRPA,
CONFIDENCE_CLOSE,
CONFIDENCE_FAR,
CONFIDENCE_NEARBY,
CONFIDENCE_VERY_CLOSE,
DEVICE_STALE_TIMEOUT,
MANUFACTURER_NAMES,
MAX_RSSI_SAMPLES,
PROTOCOL_BLE,
PROTOCOL_CLASSIC,
RANGE_CLOSE,
RANGE_FAR,
RANGE_NEARBY,
RANGE_UNKNOWN,
RANGE_VERY_CLOSE,
RSSI_CLOSE,
RSSI_FAR,
RSSI_NEARBY,
RSSI_VERY_CLOSE,
)
from .models import BTObservation, BTDeviceAggregate
from .device_key import generate_device_key, is_randomized_mac
from .distance import DistanceEstimator, get_distance_estimator
from .distance import get_distance_estimator
from .models import BTDeviceAggregate, BTObservation
from .ring_buffer import RingBuffer, get_ring_buffer
from .tracker_signatures import (
TrackerSignatureEngine,
get_tracker_engine,
TrackerDetectionResult,
)
@@ -59,7 +56,7 @@ class DeviceAggregator:
self._lock = threading.Lock()
self._max_rssi_samples = max_rssi_samples
self._baseline_device_ids: set[str] = set()
self._baseline_set_time: Optional[datetime] = None
self._baseline_set_time: datetime | None = None
# Proximity estimation components
self._distance_estimator = get_distance_estimator()
@@ -382,9 +379,8 @@ class DeviceAggregator:
def _merge_device_info(self, device: BTDeviceAggregate, observation: BTObservation) -> None:
"""Merge observation data into device aggregate (prefer non-None values)."""
# Name (prefer longer names as they're usually more complete)
if observation.name:
if not device.name or len(observation.name) > len(device.name):
device.name = observation.name
if observation.name and (not device.name or len(observation.name) > len(device.name)):
device.name = observation.name
# Manufacturer
if observation.manufacturer_id is not None:
@@ -416,7 +412,7 @@ class DeviceAggregator:
device.is_paired = observation.is_paired
device.is_connected = observation.is_connected
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
def get_device(self, device_id: str) -> BTDeviceAggregate | None:
"""Get a device by ID."""
with self._lock:
return self._devices.get(device_id)
@@ -511,7 +507,7 @@ class DeviceAggregator:
"""Access the ring buffer for timeseries data."""
return self._ring_buffer
def get_device_by_key(self, device_key: str) -> Optional[BTDeviceAggregate]:
def get_device_by_key(self, device_key: str) -> BTDeviceAggregate | None:
"""Get a device by its stable device key."""
with self._lock:
# Find device_id from device_key
+6 -8
View File
@@ -10,11 +10,10 @@ import os
import re
import shutil
import subprocess
from typing import Optional
from .constants import (
BLUEZ_SERVICE,
BLUEZ_PATH,
BLUEZ_SERVICE,
SUBPROCESS_TIMEOUT_SHORT,
)
from .models import SystemCapabilities
@@ -82,7 +81,7 @@ def _check_bluez(caps: SystemCapabilities) -> None:
# Check if BlueZ service exists
try:
obj = bus.get_object(BLUEZ_SERVICE, BLUEZ_PATH)
bus.get_object(BLUEZ_SERVICE, BLUEZ_PATH)
caps.has_bluez = True
# Try to get BlueZ version from bluetoothd
@@ -296,17 +295,16 @@ def _determine_recommended_backend(caps: SystemCapabilities) -> None:
# DBus is last resort - won't work properly with Flask but keep as option
# for potential future use with a separate scanning daemon
if caps.has_dbus and caps.has_bluez and caps.adapters:
if not caps.is_soft_blocked and not caps.is_hard_blocked:
caps.recommended_backend = 'dbus'
return
if caps.has_dbus and caps.has_bluez and caps.adapters and not caps.is_soft_blocked and not caps.is_hard_blocked:
caps.recommended_backend = 'dbus'
return
caps.recommended_backend = 'none'
if not caps.issues:
caps.issues.append('No suitable Bluetooth scanning backend available')
def quick_adapter_check() -> Optional[str]:
def quick_adapter_check() -> str | None:
"""
Quick check to find a working adapter.
+16 -27
View File
@@ -9,25 +9,22 @@ from __future__ import annotations
import logging
import threading
from datetime import datetime
from typing import Callable, Optional
from typing import Callable
from .constants import (
BLUEZ_SERVICE,
BLUEZ_PATH,
BLUEZ_ADAPTER_INTERFACE,
BLUEZ_DEVICE_INTERFACE,
DBUS_PROPERTIES_INTERFACE,
DBUS_OBJECT_MANAGER_INTERFACE,
DISCOVERY_FILTER_TRANSPORT,
DISCOVERY_FILTER_RSSI,
DISCOVERY_FILTER_DUPLICATE_DATA,
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM,
BLUEZ_ADAPTER_INTERFACE,
BLUEZ_DEVICE_INTERFACE,
BLUEZ_SERVICE,
DBUS_OBJECT_MANAGER_INTERFACE,
DBUS_PROPERTIES_INTERFACE,
DISCOVERY_FILTER_DUPLICATE_DATA,
MAJOR_DEVICE_CLASSES,
MINOR_AUDIO_VIDEO,
MINOR_PHONE,
MINOR_COMPUTER,
MINOR_PERIPHERAL,
MINOR_PHONE,
MINOR_WEARABLE,
)
from .models import BTObservation
@@ -44,8 +41,8 @@ class DBusScanner:
def __init__(
self,
adapter_path: Optional[str] = None,
on_observation: Optional[Callable[[BTObservation], None]] = None,
adapter_path: str | None = None,
on_observation: Callable[[BTObservation], None] | None = None,
):
"""
Initialize DBus scanner.
@@ -59,7 +56,7 @@ class DBusScanner:
self._bus = None
self._adapter = None
self._mainloop = None
self._mainloop_thread: Optional[threading.Thread] = None
self._mainloop_thread: threading.Thread | None = None
self._is_scanning = False
self._lock = threading.Lock()
self._known_devices: set[str] = set()
@@ -98,7 +95,7 @@ class DBusScanner:
adapter_obj = self._bus.get_object(BLUEZ_SERVICE, self._adapter_path)
self._adapter = dbus.Interface(adapter_obj, BLUEZ_ADAPTER_INTERFACE)
adapter_props = dbus.Interface(adapter_obj, DBUS_PROPERTIES_INTERFACE)
dbus.Interface(adapter_obj, DBUS_PROPERTIES_INTERFACE)
# Set up signal handlers
self._bus.add_signal_receiver(
@@ -200,7 +197,7 @@ class DBusScanner:
except Exception as e:
logger.error(f"Mainloop error: {e}")
def _find_default_adapter(self) -> Optional[str]:
def _find_default_adapter(self) -> str | None:
"""Find the default Bluetooth adapter via DBus."""
try:
import dbus
@@ -307,11 +304,7 @@ class DBusScanner:
manufacturer_id = int(mid)
# Handle various DBus data types safely
try:
if isinstance(mdata, (bytes, bytearray)):
manufacturer_data = bytes(mdata)
elif isinstance(mdata, dbus.Array):
manufacturer_data = bytes(mdata)
elif isinstance(mdata, (list, tuple)):
if isinstance(mdata, (bytes, bytearray, dbus.Array, list, tuple)):
manufacturer_data = bytes(mdata)
elif isinstance(mdata, str):
manufacturer_data = bytes.fromhex(mdata)
@@ -330,11 +323,7 @@ class DBusScanner:
if 'ServiceData' in props:
for uuid, data in props['ServiceData'].items():
try:
if isinstance(data, (bytes, bytearray)):
service_data[str(uuid)] = bytes(data)
elif isinstance(data, dbus.Array):
service_data[str(uuid)] = bytes(data)
elif isinstance(data, (list, tuple)):
if isinstance(data, (bytes, bytearray, dbus.Array, list, tuple)):
service_data[str(uuid)] = bytes(data)
elif isinstance(data, str):
service_data[str(uuid)] = bytes.fromhex(data)
@@ -389,7 +378,7 @@ class DBusScanner:
except Exception as e:
logger.error(f"Failed to process device properties: {e}")
def _decode_class_of_device(self, cod: int) -> tuple[Optional[str], Optional[str]]:
def _decode_class_of_device(self, cod: int) -> tuple[str | None, str | None]:
"""Decode Bluetooth Class of Device."""
# Major class is bits 12-8 (5 bits)
major_num = (cod >> 8) & 0x1F
+7 -8
View File
@@ -7,7 +7,6 @@ Generates consistent identifiers for devices even when MAC addresses rotate.
from __future__ import annotations
import hashlib
from typing import Optional
from .constants import (
ADDRESS_TYPE_PUBLIC,
@@ -19,10 +18,10 @@ from .constants import (
def generate_device_key(
address: str,
address_type: str,
identity_address: Optional[str] = None,
name: Optional[str] = None,
manufacturer_id: Optional[int] = None,
service_uuids: Optional[list[str]] = None,
identity_address: str | None = None,
name: str | None = None,
manufacturer_id: int | None = None,
service_uuids: list[str] | None = None,
) -> str:
"""
Generate a stable device key for identifying a Bluetooth device.
@@ -61,9 +60,9 @@ def generate_device_key(
def _generate_fingerprint_key(
address: str,
name: Optional[str],
manufacturer_id: Optional[int],
service_uuids: Optional[list[str]],
name: str | None,
manufacturer_id: int | None,
service_uuids: list[str] | None,
) -> str:
"""
Generate a fingerprint-based key for devices with random addresses.
+10 -11
View File
@@ -8,7 +8,6 @@ and EMA smoothing for RSSI values.
from __future__ import annotations
from enum import Enum
from typing import Optional
class ProximityBand(str, Enum):
@@ -70,9 +69,9 @@ class DistanceEstimator:
def estimate_distance(
self,
rssi: float,
tx_power: Optional[int] = None,
variance: Optional[float] = None,
) -> tuple[Optional[float], float]:
tx_power: int | None = None,
variance: float | None = None,
) -> tuple[float | None, float]:
"""
Estimate distance to a device based on RSSI.
@@ -143,7 +142,7 @@ class DistanceEstimator:
else:
return 15.0 # Very far: ~15m
def _calculate_variance_confidence(self, variance: Optional[float]) -> float:
def _calculate_variance_confidence(self, variance: float | None) -> float:
"""
Calculate confidence based on RSSI variance.
@@ -169,8 +168,8 @@ class DistanceEstimator:
def classify_proximity_band(
self,
distance_m: Optional[float] = None,
rssi_ema: Optional[float] = None,
distance_m: float | None = None,
rssi_ema: float | None = None,
) -> ProximityBand:
"""
Classify device into a proximity band.
@@ -209,8 +208,8 @@ class DistanceEstimator:
def apply_ema_smoothing(
self,
current: int,
prev_ema: Optional[float] = None,
alpha: Optional[float] = None,
prev_ema: float | None = None,
alpha: float | None = None,
) -> float:
"""
Apply Exponential Moving Average smoothing to RSSI.
@@ -237,7 +236,7 @@ class DistanceEstimator:
self,
rssi_samples: list[tuple],
window_seconds: int = 60,
) -> tuple[Optional[int], Optional[int]]:
) -> tuple[int | None, int | None]:
"""
Get min/max RSSI from the last N seconds.
@@ -263,7 +262,7 @@ class DistanceEstimator:
# Module-level instance for convenience
_default_estimator: Optional[DistanceEstimator] = None
_default_estimator: DistanceEstimator | None = None
def get_distance_estimator() -> DistanceEstimator:
+20 -29
View File
@@ -16,20 +16,19 @@ import re
import subprocess
import threading
from datetime import datetime
from typing import Callable, Optional
from typing import Callable
from .constants import (
BLEAK_SCAN_TIMEOUT,
HCITOOL_TIMEOUT,
BLUETOOTHCTL_TIMEOUT,
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_UUID,
MANUFACTURER_NAMES,
BLEAK_SCAN_TIMEOUT,
)
# CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits
_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')
import contextlib
from .models import BTObservation
logger = logging.getLogger(__name__)
@@ -44,12 +43,12 @@ class BleakScanner:
def __init__(
self,
on_observation: Optional[Callable[[BTObservation], None]] = None,
on_observation: Callable[[BTObservation], None] | None = None,
):
self._on_observation = on_observation
self._scanner = None
self._is_scanning = False
self._scan_thread: Optional[threading.Thread] = None
self._scan_thread: threading.Thread | None = None
self._stop_event = threading.Event()
def start(self, duration: float = BLEAK_SCAN_TIMEOUT) -> bool:
@@ -153,9 +152,7 @@ class BleakScanner:
manufacturer_id = mid
# Handle various data types safely
try:
if isinstance(mdata, (bytes, bytearray)):
manufacturer_data = bytes(mdata)
elif isinstance(mdata, (list, tuple)):
if isinstance(mdata, (bytes, bytearray, list, tuple)):
manufacturer_data = bytes(mdata)
elif isinstance(mdata, str):
manufacturer_data = bytes.fromhex(mdata)
@@ -170,9 +167,7 @@ class BleakScanner:
if adv_data.service_data:
for uuid, data in adv_data.service_data.items():
try:
if isinstance(data, (bytes, bytearray)):
service_data[str(uuid)] = bytes(data)
elif isinstance(data, (list, tuple)):
if isinstance(data, (bytes, bytearray, list, tuple)):
service_data[str(uuid)] = bytes(data)
elif isinstance(data, str):
service_data[str(uuid)] = bytes.fromhex(data)
@@ -206,13 +201,13 @@ class HcitoolScanner:
def __init__(
self,
adapter: str = 'hci0',
on_observation: Optional[Callable[[BTObservation], None]] = None,
on_observation: Callable[[BTObservation], None] | None = None,
):
self._adapter = adapter
self._on_observation = on_observation
self._process: Optional[subprocess.Popen] = None
self._process: subprocess.Popen | None = None
self._is_scanning = False
self._reader_thread: Optional[threading.Thread] = None
self._reader_thread: threading.Thread | None = None
self._stop_event = threading.Event()
def start(self) -> bool:
@@ -275,14 +270,12 @@ class HcitoolScanner:
try:
# Also start hcidump in parallel for RSSI values
dump_process = None
try:
with contextlib.suppress(Exception):
dump_process = subprocess.Popen(
['hcidump', '-i', self._adapter, '--raw'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except Exception:
pass
while not self._stop_event.is_set() and self._process:
line = self._process.stdout.readline()
@@ -323,12 +316,12 @@ class BluetoothctlScanner:
def __init__(
self,
on_observation: Optional[Callable[[BTObservation], None]] = None,
on_observation: Callable[[BTObservation], None] | None = None,
):
self._on_observation = on_observation
self._process: Optional[subprocess.Popen] = None
self._process: subprocess.Popen | None = None
self._is_scanning = False
self._reader_thread: Optional[threading.Thread] = None
self._reader_thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._devices: dict[str, dict] = {}
@@ -379,10 +372,8 @@ class BluetoothctlScanner:
self._process.stdin.flush()
self._process.wait(timeout=2.0)
except Exception:
try:
with contextlib.suppress(Exception):
self._process.terminate()
except Exception:
pass
self._process = None
if self._reader_thread:
@@ -498,12 +489,12 @@ class FallbackScanner:
def __init__(
self,
adapter: str = 'hci0',
on_observation: Optional[Callable[[BTObservation], None]] = None,
on_observation: Callable[[BTObservation], None] | None = None,
):
self._adapter = adapter
self._on_observation = on_observation
self._active_scanner: Optional[object] = None
self._backend: Optional[str] = None
self._active_scanner: object | None = None
self._backend: str | None = None
def start(self) -> bool:
"""Start scanning with best available backend."""
@@ -563,5 +554,5 @@ class FallbackScanner:
return self._active_scanner.is_scanning if self._active_scanner else False
@property
def backend(self) -> Optional[str]:
def backend(self) -> str | None:
return self._backend
+3 -8
View File
@@ -7,15 +7,13 @@ Provides factual, observable heuristics without making tracker detection claims.
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Optional
from .constants import (
BEACON_INTERVAL_MAX_VARIANCE,
PERSISTENT_MIN_SEEN_COUNT,
PERSISTENT_WINDOW_SECONDS,
BEACON_INTERVAL_MAX_VARIANCE,
STRONG_RSSI_THRESHOLD,
STABLE_VARIANCE_THRESHOLD,
STRONG_RSSI_THRESHOLD,
)
from .models import BTDeviceAggregate
@@ -111,10 +109,7 @@ class HeuristicsEngine:
return False
# Must have reasonable sample count for confidence
if len(device.rssi_samples) < 5:
return False
return True
return not len(device.rssi_samples) < 5
def _calculate_intervals(self, device: BTDeviceAggregate) -> list[float]:
"""Calculate time intervals between observations."""
+57 -61
View File
@@ -6,26 +6,22 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from .constants import (
MANUFACTURER_NAMES,
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_RPA,
ADDRESS_TYPE_NRPA,
RANGE_UNKNOWN,
PROTOCOL_BLE,
PROXIMITY_UNKNOWN,
get_appearance_name,
)
# Import tracker types (will be available after tracker_signatures module loads)
# Use string type hints to avoid circular imports
from typing import TYPE_CHECKING
from .constants import (
ADDRESS_TYPE_PUBLIC,
MANUFACTURER_NAMES,
PROTOCOL_BLE,
PROXIMITY_UNKNOWN,
RANGE_UNKNOWN,
get_appearance_name,
)
if TYPE_CHECKING:
from .tracker_signatures import TrackerDetectionResult, DeviceFingerprint
pass
@dataclass
@@ -35,21 +31,21 @@ class BTObservation:
timestamp: datetime
address: str
address_type: str = ADDRESS_TYPE_PUBLIC # public, random, random_static, rpa, nrpa
rssi: Optional[int] = None
tx_power: Optional[int] = None
name: Optional[str] = None
manufacturer_id: Optional[int] = None
manufacturer_data: Optional[bytes] = None
rssi: int | None = None
tx_power: int | None = None
name: str | None = None
manufacturer_id: int | None = None
manufacturer_data: bytes | None = None
service_uuids: list[str] = field(default_factory=list)
service_data: dict[str, bytes] = field(default_factory=dict)
appearance: Optional[int] = None
appearance: int | None = None
is_connectable: bool = False
is_paired: bool = False
is_connected: bool = False
class_of_device: Optional[int] = None # Classic BT only
major_class: Optional[str] = None
minor_class: Optional[str] = None
adapter_id: Optional[str] = None
class_of_device: int | None = None # Classic BT only
major_class: str | None = None
minor_class: str | None = None
adapter_id: str | None = None
@property
def device_id(self) -> str:
@@ -57,7 +53,7 @@ class BTObservation:
return f"{self.address}:{self.address_type}"
@property
def manufacturer_name(self) -> Optional[str]:
def manufacturer_name(self) -> str | None:
"""Look up manufacturer name from ID."""
if self.manufacturer_id is not None:
return MANUFACTURER_NAMES.get(self.manufacturer_id)
@@ -105,11 +101,11 @@ class BTDeviceAggregate:
# RSSI aggregation (capped at MAX_RSSI_SAMPLES samples)
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: Optional[int] = None
rssi_median: Optional[float] = None
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_variance: Optional[float] = None
rssi_current: int | None = None
rssi_median: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_variance: float | None = None
rssi_confidence: float = 0.0 # 0.0-1.0
# Range band (very_close/close/nearby/far/unknown) - legacy
@@ -117,27 +113,27 @@ class BTDeviceAggregate:
range_confidence: float = 0.0
# Proximity band (new system: immediate/near/far/unknown)
device_key: Optional[str] = None
device_key: str | None = None
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: Optional[float] = None
estimated_distance_m: float | None = None
distance_confidence: float = 0.0
rssi_ema: Optional[float] = None
rssi_60s_min: Optional[int] = None
rssi_60s_max: Optional[int] = None
rssi_ema: float | None = None
rssi_60s_min: int | None = None
rssi_60s_max: int | None = None
is_randomized_mac: bool = False
threat_tags: list[str] = field(default_factory=list)
# Device info (merged from observations)
name: Optional[str] = None
manufacturer_id: Optional[int] = None
manufacturer_name: Optional[str] = None
manufacturer_bytes: Optional[bytes] = None
name: str | None = None
manufacturer_id: int | None = None
manufacturer_name: str | None = None
manufacturer_bytes: bytes | None = None
service_uuids: list[str] = field(default_factory=list)
tx_power: Optional[int] = None
appearance: Optional[int] = None
class_of_device: Optional[int] = None
major_class: Optional[str] = None
minor_class: Optional[str] = None
tx_power: int | None = None
appearance: int | None = None
class_of_device: int | None = None
major_class: str | None = None
minor_class: str | None = None
is_connectable: bool = False
is_paired: bool = False
is_connected: bool = False
@@ -151,14 +147,14 @@ class BTDeviceAggregate:
# Baseline tracking
in_baseline: bool = False
baseline_id: Optional[int] = None
baseline_id: int | None = None
seen_before: bool = False
# Tracker detection fields
is_tracker: bool = False
tracker_type: Optional[str] = None # 'airtag', 'tile', 'samsung_smarttag', etc.
tracker_name: Optional[str] = None
tracker_confidence: Optional[str] = None # 'high', 'medium', 'low', 'none'
tracker_type: str | None = None # 'airtag', 'tile', 'samsung_smarttag', etc.
tracker_name: str | None = None
tracker_confidence: str | None = None # 'high', 'medium', 'low', 'none'
tracker_confidence_score: float = 0.0 # 0.0 to 1.0
tracker_evidence: list[str] = field(default_factory=list)
@@ -167,11 +163,11 @@ class BTDeviceAggregate:
risk_factors: list[str] = field(default_factory=list)
# IRK (Identity Resolving Key) from paired device database
irk_hex: Optional[str] = None # 32-char hex if known
irk_source_name: Optional[str] = None # Name from paired DB
irk_hex: str | None = None # 32-char hex if known
irk_source_name: str | None = None # Name from paired DB
# Payload fingerprint (survives MAC randomization)
payload_fingerprint_id: Optional[str] = None
payload_fingerprint_id: str | None = None
payload_fingerprint_stability: float = 0.0
# Service data (for tracker analysis)
@@ -379,22 +375,22 @@ class ScanStatus:
is_scanning: bool = False
mode: str = 'auto' # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto'
backend: Optional[str] = None # Active backend being used
adapter_id: Optional[str] = None
started_at: Optional[datetime] = None
duration_s: Optional[int] = None
backend: str | None = None # Active backend being used
adapter_id: str | None = None
started_at: datetime | None = None
duration_s: int | None = None
devices_found: int = 0
error: Optional[str] = None
error: str | None = None
@property
def elapsed_seconds(self) -> Optional[float]:
def elapsed_seconds(self) -> float | None:
"""Seconds since scan started."""
if self.started_at:
return (datetime.now() - self.started_at).total_seconds()
return None
@property
def remaining_seconds(self) -> Optional[float]:
def remaining_seconds(self) -> float | None:
"""Seconds remaining if duration was set."""
if self.duration_s and self.elapsed_seconds:
return max(0, self.duration_s - self.elapsed_seconds)
@@ -423,11 +419,11 @@ class SystemCapabilities:
# DBus/BlueZ
has_dbus: bool = False
has_bluez: bool = False
bluez_version: Optional[str] = None
bluez_version: str | None = None
# Adapters
adapters: list[dict] = field(default_factory=list)
default_adapter: Optional[str] = None
default_adapter: str | None = None
# Permissions
has_bluetooth_permission: bool = False
+7 -9
View File
@@ -10,8 +10,6 @@ from __future__ import annotations
import threading
from collections import deque
from datetime import datetime, timedelta
from typing import Optional
# Default configuration
DEFAULT_RETENTION_MINUTES = 30
@@ -58,7 +56,7 @@ class RingBuffer:
self,
device_key: str,
rssi: int,
timestamp: Optional[datetime] = None,
timestamp: datetime | None = None,
) -> bool:
"""
Ingest an RSSI observation for a device.
@@ -99,7 +97,7 @@ class RingBuffer:
def get_timeseries(
self,
device_key: str,
window_minutes: Optional[int] = None,
window_minutes: int | None = None,
downsample_seconds: int = 10,
) -> list[dict]:
"""
@@ -131,9 +129,9 @@ class RingBuffer:
def get_all_timeseries(
self,
window_minutes: Optional[int] = None,
window_minutes: int | None = None,
downsample_seconds: int = 10,
top_n: Optional[int] = None,
top_n: int | None = None,
sort_by: str = 'recency',
) -> dict[str, list[dict]]:
"""
@@ -265,7 +263,7 @@ class RingBuffer:
with self._lock:
return len(self._observations)
def get_observation_count(self, device_key: Optional[str] = None) -> int:
def get_observation_count(self, device_key: str | None = None) -> int:
"""
Get total observation count.
@@ -287,7 +285,7 @@ class RingBuffer:
self._observations.clear()
self._last_ingested.clear()
def get_device_stats(self, device_key: str) -> Optional[dict]:
def get_device_stats(self, device_key: str) -> dict | None:
"""
Get statistics for a specific device.
@@ -316,7 +314,7 @@ class RingBuffer:
# Module-level instance for shared access
_ring_buffer: Optional[RingBuffer] = None
_ring_buffer: RingBuffer | None = None
def get_ring_buffer() -> RingBuffer:
+19 -23
View File
@@ -9,30 +9,26 @@ from __future__ import annotations
import logging
import queue
import threading
import time
from collections.abc import Generator
from datetime import datetime
from typing import Callable, Generator, Optional
from typing import Callable
from .aggregator import DeviceAggregator
from .capability_check import check_capabilities
from .constants import (
DEFAULT_SCAN_DURATION,
DEVICE_STALE_TIMEOUT,
PROTOCOL_AUTO,
PROTOCOL_BLE,
PROTOCOL_CLASSIC,
)
from .dbus_scanner import DBusScanner
from .fallback_scanner import FallbackScanner
from .ubertooth_scanner import UbertoothScanner
from .heuristics import HeuristicsEngine
from .irk_extractor import get_paired_irks
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
from .ubertooth_scanner import UbertoothScanner
logger = logging.getLogger(__name__)
# Global scanner instance
_scanner_instance: Optional['BluetoothScanner'] = None
_scanner_instance: BluetoothScanner | None = None
_scanner_lock = threading.Lock()
@@ -43,7 +39,7 @@ class BluetoothScanner:
Provides unified API for scanning, device aggregation, and heuristics.
"""
def __init__(self, adapter_id: Optional[str] = None):
def __init__(self, adapter_id: str | None = None):
"""
Initialize Bluetooth scanner.
@@ -57,27 +53,27 @@ class BluetoothScanner:
self._lock = threading.Lock()
# Scanner backends
self._dbus_scanner: Optional[DBusScanner] = None
self._fallback_scanner: Optional[FallbackScanner] = None
self._ubertooth_scanner: Optional[UbertoothScanner] = None
self._active_backend: Optional[str] = None
self._dbus_scanner: DBusScanner | None = None
self._fallback_scanner: FallbackScanner | None = None
self._ubertooth_scanner: UbertoothScanner | None = None
self._active_backend: str | None = None
# Event queue for SSE streaming
self._event_queue: queue.Queue = queue.Queue(maxsize=1000)
# Duration-based scanning
self._scan_timer: Optional[threading.Timer] = None
self._scan_timer: threading.Timer | None = None
# Callbacks
self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = []
# Capability check result
self._capabilities: Optional[SystemCapabilities] = None
self._capabilities: SystemCapabilities | None = None
def start_scan(
self,
mode: str = 'auto',
duration_s: Optional[int] = None,
duration_s: int | None = None,
transport: str = 'auto',
rssi_threshold: int = -100,
) -> bool:
@@ -160,7 +156,7 @@ class BluetoothScanner:
adapter: str,
transport: str,
rssi_threshold: int
) -> tuple[bool, Optional[str]]:
) -> tuple[bool, str | None]:
"""Start DBus scanner."""
try:
self._dbus_scanner = DBusScanner(
@@ -173,7 +169,7 @@ class BluetoothScanner:
logger.warning(f"DBus scanner failed: {e}")
return False, None
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
def _start_ubertooth(self) -> tuple[bool, str | None]:
"""Start Ubertooth One scanner."""
try:
self._ubertooth_scanner = UbertoothScanner(
@@ -185,7 +181,7 @@ class BluetoothScanner:
logger.warning(f"Ubertooth scanner failed: {e}")
return False, None
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, str | None]:
"""Start fallback scanner."""
try:
# Extract adapter name from path if needed
@@ -342,8 +338,8 @@ class BluetoothScanner:
self,
sort_by: str = 'last_seen',
sort_desc: bool = True,
min_rssi: Optional[int] = None,
protocol: Optional[str] = None,
min_rssi: int | None = None,
protocol: str | None = None,
max_age_seconds: float = DEVICE_STALE_TIMEOUT,
) -> list[BTDeviceAggregate]:
"""
@@ -382,7 +378,7 @@ class BluetoothScanner:
return devices
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
def get_device(self, device_id: str) -> BTDeviceAggregate | None:
"""Get a specific device by ID."""
return self._aggregator.get_device(device_id)
@@ -491,7 +487,7 @@ class BluetoothScanner:
return self._aggregator.has_baseline
def get_bluetooth_scanner(adapter_id: Optional[str] = None) -> BluetoothScanner:
def get_bluetooth_scanner(adapter_id: str | None = None) -> BluetoothScanner:
"""
Get or create the global Bluetooth scanner instance.
+45 -50
View File
@@ -18,7 +18,6 @@ import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional
logger = logging.getLogger('intercept.bluetooth.tracker_signatures')
@@ -108,7 +107,7 @@ class TrackerSignature:
tracker_type: TrackerType
name: str
description: str
company_id: Optional[int] = None
company_id: int | None = None
company_ids: list[int] = field(default_factory=list)
manufacturer_data_prefixes: list[bytes] = field(default_factory=list)
service_uuids: list[str] = field(default_factory=list)
@@ -218,15 +217,15 @@ class TrackerDetectionResult:
confidence: TrackerConfidence = TrackerConfidence.NONE
confidence_score: float = 0.0 # 0.0 to 1.0
evidence: list[str] = field(default_factory=list)
matched_signature: Optional[str] = None
matched_signature: str | None = None
# For suspicious presence heuristics
risk_factors: list[str] = field(default_factory=list)
risk_score: float = 0.0 # 0.0 to 1.0
# Raw data used for detection
manufacturer_id: Optional[int] = None
manufacturer_data_hex: Optional[str] = None
manufacturer_id: int | None = None
manufacturer_data_hex: str | None = None
service_uuids_found: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
@@ -264,13 +263,13 @@ class DeviceFingerprint:
fingerprint_id: str # SHA256 hash of stable features
# Features used for fingerprinting
manufacturer_id: Optional[int] = None
manufacturer_data_prefix: Optional[bytes] = None # First 4 bytes (stable across MACs)
manufacturer_id: int | None = None
manufacturer_data_prefix: bytes | None = None # First 4 bytes (stable across MACs)
manufacturer_data_length: int = 0
service_uuids: list[str] = field(default_factory=list)
service_data_keys: list[str] = field(default_factory=list)
tx_power_bucket: Optional[str] = None # "high"/"medium"/"low"
name_hint: Optional[str] = None
tx_power_bucket: str | None = None # "high"/"medium"/"low"
name_hint: str | None = None
# Confidence in this fingerprint's stability
stability_confidence: float = 0.5 # 0.0-1.0
@@ -291,12 +290,12 @@ class DeviceFingerprint:
def generate_fingerprint(
manufacturer_id: Optional[int],
manufacturer_data: Optional[bytes],
manufacturer_id: int | None,
manufacturer_data: bytes | None,
service_uuids: list[str],
service_data: dict[str, bytes],
tx_power: Optional[int],
name: Optional[str],
tx_power: int | None,
name: str | None,
) -> DeviceFingerprint:
"""
Generate a stable fingerprint for a BLE device.
@@ -407,12 +406,12 @@ class TrackerSignatureEngine:
self,
address: str,
address_type: str,
name: Optional[str] = None,
manufacturer_id: Optional[int] = None,
manufacturer_data: Optional[bytes] = None,
service_uuids: Optional[list[str]] = None,
service_data: Optional[dict[str, bytes]] = None,
tx_power: Optional[int] = None,
name: str | None = None,
manufacturer_id: int | None = None,
manufacturer_data: bytes | None = None,
service_uuids: list[str] | None = None,
service_data: dict[str, bytes] | None = None,
tx_power: int | None = None,
) -> TrackerDetectionResult:
"""
Analyze a BLE device for tracker indicators.
@@ -502,9 +501,9 @@ class TrackerSignatureEngine:
self,
signature: TrackerSignature,
address: str,
name: Optional[str],
manufacturer_id: Optional[int],
manufacturer_data: Optional[bytes],
name: str | None,
manufacturer_id: int | None,
manufacturer_data: bytes | None,
normalized_uuids: list[str],
service_data: dict[str, bytes],
) -> tuple[float, list[str]]:
@@ -517,9 +516,7 @@ class TrackerSignatureEngine:
# Many Apple devices (AirPods, Watch, etc.) share the same manufacturer ID
company_id_matches = False
if manufacturer_id is not None:
if signature.company_id == manufacturer_id:
company_id_matches = True
elif manufacturer_id in signature.company_ids:
if signature.company_id == manufacturer_id or manufacturer_id in signature.company_ids:
company_id_matches = True
# For Apple devices, only add company ID score if we also have Find My indicators
@@ -592,8 +589,8 @@ class TrackerSignatureEngine:
self,
address: str,
address_type: str,
manufacturer_id: Optional[int],
manufacturer_data: Optional[bytes],
manufacturer_id: int | None,
manufacturer_data: bytes | None,
normalized_uuids: list[str],
) -> tuple[float, list[str]]:
"""Check for generic tracker-like indicators."""
@@ -606,12 +603,11 @@ class TrackerSignatureEngine:
evidence.append('Uses Apple Find My network service (fd6f)')
# Apple manufacturer with Find My advertisement type
if manufacturer_id == APPLE_COMPANY_ID and manufacturer_data:
if len(manufacturer_data) >= 2:
adv_type = manufacturer_data[0]
if adv_type == APPLE_FINDMY_ADV_TYPE:
score += 0.35
evidence.append('Apple Find My network advertisement detected')
if manufacturer_id == APPLE_COMPANY_ID and manufacturer_data and len(manufacturer_data) >= 2:
adv_type = manufacturer_data[0]
if adv_type == APPLE_FINDMY_ADV_TYPE:
score += 0.35
evidence.append('Apple Find My network advertisement detected')
# Check for beacon-like service UUIDs
for beacon_uuid in BEACON_SERVICE_UUIDS:
@@ -628,10 +624,9 @@ class TrackerSignatureEngine:
evidence.append('Uses randomized MAC address')
# Small manufacturer data payload typical of beacons
if manufacturer_data and 20 <= len(manufacturer_data) <= 30:
if score > 0:
score += 0.05
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
if manufacturer_data and 20 <= len(manufacturer_data) <= 30 and score > 0:
score += 0.05
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
return score, evidence
@@ -651,12 +646,12 @@ class TrackerSignatureEngine:
def generate_device_fingerprint(
self,
manufacturer_id: Optional[int],
manufacturer_data: Optional[bytes],
manufacturer_id: int | None,
manufacturer_data: bytes | None,
service_uuids: list[str],
service_data: dict[str, bytes],
tx_power: Optional[int],
name: Optional[str],
tx_power: int | None,
name: str | None,
) -> DeviceFingerprint:
"""Generate a fingerprint for device tracking across MAC rotations."""
return generate_fingerprint(
@@ -668,7 +663,7 @@ class TrackerSignatureEngine:
name=name,
)
def record_sighting(self, fingerprint_id: str, timestamp: Optional[datetime] = None) -> int:
def record_sighting(self, fingerprint_id: str, timestamp: datetime | None = None) -> int:
"""
Record a device sighting for persistence tracking.
@@ -704,7 +699,7 @@ class TrackerSignatureEngine:
seen_count: int,
duration_seconds: float,
seen_rate: float,
rssi_variance: Optional[float],
rssi_variance: float | None,
is_new: bool,
) -> tuple[float, list[str]]:
"""
@@ -765,7 +760,7 @@ class TrackerSignatureEngine:
# SINGLETON ENGINE INSTANCE
# =============================================================================
_engine_instance: Optional[TrackerSignatureEngine] = None
_engine_instance: TrackerSignatureEngine | None = None
def get_tracker_engine() -> TrackerSignatureEngine:
@@ -779,12 +774,12 @@ def get_tracker_engine() -> TrackerSignatureEngine:
def detect_tracker(
address: str,
address_type: str = 'public',
name: Optional[str] = None,
manufacturer_id: Optional[int] = None,
manufacturer_data: Optional[bytes] = None,
service_uuids: Optional[list[str]] = None,
service_data: Optional[dict[str, bytes]] = None,
tx_power: Optional[int] = None,
name: str | None = None,
manufacturer_id: int | None = None,
manufacturer_data: bytes | None = None,
service_uuids: list[str] | None = None,
service_data: dict[str, bytes] | None = None,
tx_power: int | None = None,
) -> TrackerDetectionResult:
"""
Convenience function to detect if a BLE device is a tracker.
+7 -8
View File
@@ -7,13 +7,14 @@ Provides enhanced sniffing capabilities compared to standard Bluetooth adapters.
from __future__ import annotations
import contextlib
import logging
import re
import shutil
import subprocess
import threading
from datetime import datetime
from typing import Callable, Optional
from typing import Callable
from .constants import (
ADDRESS_TYPE_PUBLIC,
@@ -38,7 +39,7 @@ class UbertoothScanner:
def __init__(
self,
device_index: int = 0,
on_observation: Optional[Callable[[BTObservation], None]] = None,
on_observation: Callable[[BTObservation], None] | None = None,
):
"""
Initialize Ubertooth scanner.
@@ -49,9 +50,9 @@ class UbertoothScanner:
"""
self._device_index = device_index
self._on_observation = on_observation
self._process: Optional[subprocess.Popen] = None
self._process: subprocess.Popen | None = None
self._is_scanning = False
self._reader_thread: Optional[threading.Thread] = None
self._reader_thread: threading.Thread | None = None
self._stop_event = threading.Event()
@staticmethod
@@ -177,7 +178,7 @@ class UbertoothScanner:
finally:
self._is_scanning = False
def _parse_advertisement(self, line: str) -> Optional[BTObservation]:
def _parse_advertisement(self, line: str) -> BTObservation | None:
"""
Parse a single ubertooth-btle output line into a BTObservation.
@@ -280,10 +281,8 @@ class UbertoothScanner:
# 0x08/0x09 = Shortened/Complete Local Name
elif ad_type in (0x08, 0x09):
try:
with contextlib.suppress(Exception):
name = ad_payload.decode('utf-8', errors='replace')
except Exception:
pass
# 0x0A = TX Power Level
elif ad_type == 0x0A and len(ad_payload) >= 1:
+4 -4
View File
@@ -12,7 +12,8 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from utils.database import add_correlation, get_correlations as db_get_correlations
from utils.database import add_correlation
from utils.database import get_correlations as db_get_correlations
logger = logging.getLogger('intercept.correlation')
@@ -243,9 +244,8 @@ class DeviceCorrelator:
if wifi_oui == bt_oui:
reasons.append("same OUI")
if wifi.manufacturer and bt.manufacturer:
if wifi.manufacturer.lower() == bt.manufacturer.lower():
reasons.append(f"same manufacturer ({wifi.manufacturer})")
if wifi.manufacturer and bt.manufacturer and wifi.manufacturer.lower() == bt.manufacturer.lower():
reasons.append(f"same manufacturer ({wifi.manufacturer})")
if wifi.rssi is not None and bt.rssi is not None:
rssi_diff = abs(wifi.rssi - bt.rssi)
+3 -3
View File
@@ -9,11 +9,10 @@ import logging
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
from werkzeug.security import generate_password_hash
from config import ADMIN_USERNAME, ADMIN_PASSWORD
logger = logging.getLogger('intercept.database')
@@ -255,9 +254,10 @@ def init_db() -> None:
cursor = conn.execute('SELECT COUNT(*) FROM users')
if cursor.fetchone()[0] == 0:
from config import ADMIN_USERNAME, ADMIN_PASSWORD
import secrets as _secrets
from config import ADMIN_PASSWORD, ADMIN_USERNAME
admin_password = ADMIN_PASSWORD
if not admin_password:
admin_password = _secrets.token_urlsafe(16)
+4 -5
View File
@@ -7,18 +7,17 @@ and routine communications per ITU-R M.493.
"""
from .constants import (
FORMAT_CODES,
DISTRESS_NATURE_CODES,
TELECOMMAND_CODES,
CATEGORY_PRIORITY,
DISTRESS_NATURE_CODES,
FORMAT_CODES,
MID_COUNTRY_MAP,
TELECOMMAND_CODES,
)
from .parser import (
parse_dsc_message,
get_country_from_mmsi,
get_distress_nature_text,
get_format_text,
parse_dsc_message,
)
__all__ = [
+10 -12
View File
@@ -27,24 +27,23 @@ from __future__ import annotations
import argparse
import json
import logging
import struct
import sys
from collections.abc import Generator
from datetime import datetime
from typing import Generator
import numpy as np
from scipy import signal as scipy_signal
from .constants import (
DISTRESS_NATURE_CODES,
DSC_AUDIO_SAMPLE_RATE,
DSC_BAUD_RATE,
DSC_MARK_FREQ,
DSC_SPACE_FREQ,
DSC_AUDIO_SAMPLE_RATE,
FORMAT_CODES,
DISTRESS_NATURE_CODES,
VALID_EOS,
TELECOMMAND_FORMATS,
MIN_SYMBOLS_FOR_FORMAT,
TELECOMMAND_FORMATS,
VALID_EOS,
)
# Configure logging
@@ -191,12 +190,11 @@ class DSCDecoder:
self.bit_buffer = self.bit_buffer[-1500:]
# Look for dot pattern (sync) - alternating 1010101...
if not self.in_message:
if self._detect_dot_pattern():
self.in_message = True
self.message_bits = []
logger.debug("DSC sync detected")
return None
if not self.in_message and self._detect_dot_pattern():
self.in_message = True
self.message_bits = []
logger.debug("DSC sync detected")
return None
# Collect message bits
if self.in_message:
+5 -8
View File
@@ -14,13 +14,13 @@ from datetime import datetime
from typing import Any
from .constants import (
FORMAT_CODES,
DISTRESS_NATURE_CODES,
TELECOMMAND_CODES,
CATEGORY_PRIORITY,
DISTRESS_NATURE_CODES,
FORMAT_CODES,
MID_COUNTRY_MAP,
VALID_FORMAT_SPECIFIERS,
TELECOMMAND_CODES,
VALID_EOS,
VALID_FORMAT_SPECIFIERS,
)
logger = logging.getLogger('intercept.dsc.parser')
@@ -330,10 +330,7 @@ def validate_mmsi(mmsi: str) -> bool:
return False
# All zeros is invalid
if mmsi == '000000000':
return False
return True
return mmsi != '000000000'
def classify_mmsi(mmsi: str) -> str:
+2 -4
View File
@@ -507,7 +507,7 @@ def get_current_position() -> GPSPosition | None:
# GPS device detection and gpsd auto-start
# ============================================
_gpsd_process: 'subprocess.Popen | None' = None
_gpsd_process: subprocess.Popen | None = None
_gpsd_process_lock = threading.RLock()
@@ -672,10 +672,8 @@ def stop_gpsd_daemon() -> None:
_gpsd_process.terminate()
_gpsd_process.wait(timeout=3.0)
except Exception:
try:
with contextlib.suppress(Exception):
_gpsd_process.kill()
except Exception:
pass
logger.info("Stopped gpsd daemon")
print("[GPS] Stopped gpsd daemon", flush=True)
_gpsd_process = None
+11 -15
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import struct
import threading
import time
from typing import Optional, Callable
from typing import Callable
try:
import websocket # websocket-client library
@@ -17,6 +17,8 @@ try:
except ImportError:
WEBSOCKET_CLIENT_AVAILABLE = False
import contextlib
from utils.logging import get_logger
logger = get_logger('intercept.kiwisdr')
@@ -78,9 +80,9 @@ class KiwiSDRClient:
self,
host: str,
port: int = KIWI_DEFAULT_PORT,
on_audio: Optional[Callable[[bytes, int], None]] = None,
on_error: Optional[Callable[[str], None]] = None,
on_disconnect: Optional[Callable[[], None]] = None,
on_audio: Callable[[bytes, int], None] | None = None,
on_error: Callable[[str], None] | None = None,
on_disconnect: Callable[[], None] | None = None,
password: str = '',
):
self.host = host
@@ -93,8 +95,8 @@ class KiwiSDRClient:
self._ws = None
self._connected = False
self._stopping = False
self._receive_thread: Optional[threading.Thread] = None
self._keepalive_thread: Optional[threading.Thread] = None
self._receive_thread: threading.Thread | None = None
self._keepalive_thread: threading.Thread | None = None
self._send_lock = threading.Lock()
self.frequency_khz: float = 0
@@ -230,10 +232,8 @@ class KiwiSDRClient:
if not self._stopping:
self._connected = False
if self._on_disconnect:
try:
with contextlib.suppress(Exception):
self._on_disconnect()
except Exception:
pass
def _parse_snd_frame(self, data: bytes) -> None:
"""Parse a KiwiSDR SND binary frame."""
@@ -255,10 +255,8 @@ class KiwiSDRClient:
pcm_data = data[KIWI_SND_HEADER_SIZE:]
if pcm_data and self._on_audio:
try:
with contextlib.suppress(Exception):
self._on_audio(pcm_data, smeter_raw)
except Exception:
pass
def _keepalive_loop(self) -> None:
"""Background thread: send keepalive every 5 seconds."""
@@ -273,10 +271,8 @@ class KiwiSDRClient:
def _cleanup(self) -> None:
"""Close WebSocket and join threads."""
if self._ws:
try:
with contextlib.suppress(Exception):
self._ws.close()
except Exception:
pass
self._ws = None
if self._receive_thread and self._receive_thread.is_alive():
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
import sys
from config import LOG_LEVEL, LOG_FORMAT
from config import LOG_FORMAT, LOG_LEVEL
def get_logger(name: str) -> logging.Logger:
+10 -16
View File
@@ -13,11 +13,12 @@ Install SDK with: pip install meshtastic
from __future__ import annotations
import base64
import contextlib
import hashlib
import json
import secrets
import threading
import urllib.request
import json
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -420,10 +421,8 @@ class MeshtasticClient:
if self._running:
# Another thread connected while we were connecting — discard ours
if new_interface:
try:
with contextlib.suppress(Exception):
new_interface.close()
except Exception:
pass
return True
self._interface = new_interface
@@ -456,18 +455,12 @@ class MeshtasticClient:
def _cleanup_subscriptions(self) -> None:
"""Unsubscribe from pubsub topics."""
if HAS_MESHTASTIC:
try:
with contextlib.suppress(Exception):
pub.unsubscribe(self._on_receive, "meshtastic.receive")
except Exception:
pass
try:
with contextlib.suppress(Exception):
pub.unsubscribe(self._on_connection, "meshtastic.connection.established")
except Exception:
pass
try:
with contextlib.suppress(Exception):
pub.unsubscribe(self._on_disconnect, "meshtastic.connection.lost")
except Exception:
pass
def _on_connection(self, interface, topic=None) -> None:
"""Handle connection established event."""
@@ -1283,7 +1276,7 @@ class MeshtasticClient:
try:
my_info = self._interface.getMyNodeInfo()
if my_info:
metadata = my_info.get('deviceMetrics', {})
my_info.get('deviceMetrics', {})
# Firmware version is in the user section or metadata
if 'firmware_version' in my_info:
self._firmware_version = my_info['firmware_version']
@@ -1356,8 +1349,9 @@ class MeshtasticClient:
PNG image bytes, or None on error
"""
try:
import qrcode
from io import BytesIO
import qrcode
except ImportError:
logger.error("qrcode library not installed. Install with: pip install qrcode[pil]")
return None
@@ -1454,7 +1448,7 @@ class MeshtasticClient:
try:
# Send range test packet with sequence number
payload = f"RangeTest #{i+1}".encode('utf-8')
payload = f"RangeTest #{i+1}".encode()
self._interface.sendData(
payload,
destinationId=BROADCAST_ADDR,
+2 -6
View File
@@ -1044,9 +1044,7 @@ def morse_decoder_thread(
except queue.Empty:
now = time.monotonic()
should_emit_waiting = False
if last_pcm_at is None:
should_emit_waiting = True
elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS:
if last_pcm_at is None or (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS:
should_emit_waiting = True
if should_emit_waiting and waiting_since is None:
@@ -1308,9 +1306,7 @@ def morse_iq_decoder_thread(
except queue.Empty:
now = time.monotonic()
should_emit_waiting = False
if last_pcm_at is None:
should_emit_waiting = True
elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS:
if last_pcm_at is None or (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS:
should_emit_waiting = True
if should_emit_waiting and waiting_since is None:
+7 -15
View File
@@ -18,6 +18,7 @@ Usage with rtl_433:
from __future__ import annotations
import contextlib
import json
import logging
import queue
@@ -115,9 +116,8 @@ def ook_parser_thread(
# rtl_433 flex decoder puts hex in 'codes' (list or string),
# 'code' (singular), or 'data' depending on version.
codes = data.get('codes')
if codes is not None:
if isinstance(codes, str):
codes = [codes] if codes else None
if codes is not None and isinstance(codes, str):
codes = [codes] if codes else None
if not codes:
code = data.get('code')
@@ -134,24 +134,20 @@ def ook_parser_thread(
for _rssi_key in ('snr', 'rssi', 'level', 'noise'):
_rssi_val = data.get(_rssi_key)
if _rssi_val is not None:
try:
with contextlib.suppress(TypeError, ValueError):
rssi = round(float(_rssi_val), 1)
except (TypeError, ValueError):
pass
break
if not codes:
logger.warning(
f'[rtl_433/ook] no code field — keys: {list(data.keys())}'
)
try:
with contextlib.suppress(queue.Full):
output_queue.put_nowait({
'type': 'ook_raw',
'data': data,
'timestamp': datetime.now().strftime('%H:%M:%S'),
})
except queue.Full:
pass
continue
for code_hex in codes:
@@ -194,14 +190,10 @@ def ook_parser_thread(
except Exception as e:
logger.warning(f'OOK parser thread error: {e}')
try:
with contextlib.suppress(queue.Full):
output_queue.put_nowait({'type': 'error', 'text': str(e)})
except queue.Full:
pass
# Notify frontend that the parser has stopped (covers both normal exit
# and unexpected rtl_433 crashes so the UI doesn't stay in "Listening").
try:
with contextlib.suppress(queue.Full):
output_queue.put_nowait({'type': 'status', 'text': 'stopped'})
except queue.Full:
pass
+7 -14
View File
@@ -1,16 +1,17 @@
from __future__ import annotations
import atexit
import contextlib
import logging
import os
import platform
import re
import signal
import subprocess
import re
import threading
import time
from pathlib import Path
from typing import Any, Callable
from typing import Any
from .dependencies import check_tool
@@ -38,10 +39,8 @@ def close_process_pipes(process: subprocess.Popen) -> None:
"""Close stdin/stdout/stderr pipes on a subprocess to free file descriptors."""
for pipe in (process.stdin, process.stdout, process.stderr):
if pipe:
try:
with contextlib.suppress(OSError):
pipe.close()
except OSError:
pass
def cleanup_all_processes() -> None:
@@ -97,10 +96,8 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
return True
except subprocess.TimeoutExpired:
process.kill()
try:
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=3)
except subprocess.TimeoutExpired:
pass
close_process_pipes(process)
unregister_process(process)
return True
@@ -157,10 +154,8 @@ def cleanup_stale_processes() -> None:
# Note: dump1090 is NOT included here as users may run it as a system service
processes_to_kill = ['rtl_adsb', 'rtl_433', 'multimon-ng', 'rtl_fm']
for proc_name in processes_to_kill:
try:
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run(['pkill', '-9', proc_name], capture_output=True)
except (subprocess.SubprocessError, OSError):
pass
_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid'
@@ -240,10 +235,8 @@ def cleanup_stale_dump1090() -> None:
break
else:
# Still alive, force kill
try:
with contextlib.suppress(OSError):
os.killpg(pgid, signal.SIGKILL)
except OSError:
pass
except OSError as e:
logger.debug(f"Error killing stale dump1090 PID {pid}: {e}")
+7 -7
View File
@@ -9,7 +9,7 @@ import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Dict, Optional, Any
from typing import Any, Callable
logger = logging.getLogger('intercept.process_monitor')
@@ -21,8 +21,8 @@ class ProcessInfo:
process: Any # subprocess.Popen
started_at: datetime = field(default_factory=datetime.now)
restart_count: int = 0
last_restart: Optional[datetime] = None
restart_callback: Optional[Callable] = None
last_restart: datetime | None = None
restart_callback: Callable | None = None
max_restarts: int = 3
backoff_seconds: float = 5.0
enabled: bool = True
@@ -39,17 +39,17 @@ class ProcessMonitor:
"""
def __init__(self, check_interval: float = 5.0):
self.processes: Dict[str, ProcessInfo] = {}
self.processes: dict[str, ProcessInfo] = {}
self.check_interval = check_interval
self._running = False
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def register(
self,
name: str,
process: Any,
restart_callback: Optional[Callable] = None,
restart_callback: Callable | None = None,
max_restarts: int = 3,
backoff_seconds: float = 5.0
) -> None:
@@ -171,7 +171,7 @@ class ProcessMonitor:
with self._lock:
info.restart_count += 1
def get_status(self) -> Dict[str, Any]:
def get_status(self) -> dict[str, Any]:
"""
Get status of all monitored processes.
+6 -6
View File
@@ -25,22 +25,22 @@ from __future__ import annotations
from typing import Optional
from .airspy import AirspyCommandBuilder
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from .detection import detect_all_devices, invalidate_device_cache, probe_rtlsdr_device
from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder
from .airspy import AirspyCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder
from .validation import (
SDRValidationError,
get_capabilities_for_type,
validate_device_index,
validate_frequency,
validate_gain,
validate_sample_rate,
validate_ppm,
validate_device_index,
validate_sample_rate,
validate_squelch,
get_capabilities_for_type,
)
+9 -11
View File
@@ -8,8 +8,6 @@ Airspy HF+ supports 9 kHz - 31 MHz and 60-260 MHz.
from __future__ import annotations
from typing import Optional
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
@@ -63,10 +61,10 @@ class AirspyCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: Optional[int] = None,
squelch: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -102,7 +100,7 @@ class AirspyCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -132,8 +130,8 @@ class AirspyCommandBuilder(CommandBuilder):
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -161,7 +159,7 @@ class AirspyCommandBuilder(CommandBuilder):
def build_ais_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110
) -> list[str]:
@@ -193,8 +191,8 @@ class AirspyCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
+11 -12
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class SDRType(Enum):
@@ -49,8 +48,8 @@ class SDRDevice:
serial: str
driver: str # e.g., "rtlsdr", "lime", "hackrf"
capabilities: SDRCapabilities
rtl_tcp_host: Optional[str] = None # Remote rtl_tcp server host
rtl_tcp_port: Optional[int] = None # Remote rtl_tcp server port
rtl_tcp_host: str | None = None # Remote rtl_tcp server host
rtl_tcp_port: int | None = None # Remote rtl_tcp server port
@property
def is_network(self) -> bool:
@@ -92,10 +91,10 @@ class CommandBuilder(ABC):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: Optional[int] = None,
squelch: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -120,7 +119,7 @@ class CommandBuilder(ABC):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -141,8 +140,8 @@ class CommandBuilder(ABC):
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -164,7 +163,7 @@ class CommandBuilder(ABC):
def build_ais_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110
) -> list[str]:
@@ -192,8 +191,8 @@ class CommandBuilder(ABC):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
+8 -10
View File
@@ -6,11 +6,11 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations
import contextlib
import logging
import re
import subprocess
import time
from typing import Optional
from utils.dependencies import get_tool_path
@@ -47,10 +47,10 @@ def _hackrf_probe_blocked() -> bool:
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Get default capabilities for an SDR type."""
# Import here to avoid circular imports
from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder
from .airspy import AirspyCommandBuilder
from .hackrf import HackRFCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder
builders = {
@@ -79,7 +79,7 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
)
def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
def _driver_to_sdr_type(driver: str) -> SDRType | None:
"""Map SoapySDR driver name to SDRType."""
mapping = {
'rtlsdr': SDRType.RTL_SDR,
@@ -114,7 +114,7 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
import os
import platform
env = os.environ.copy()
if platform.system() == 'Darwin':
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '')
@@ -224,7 +224,7 @@ def _get_soapy_env() -> dict:
return env
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
def detect_soapy_devices(skip_types: set[SDRType] | None = None) -> list[SDRDevice]:
"""
Detect SDR devices via SoapySDR.
@@ -497,10 +497,8 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
# rtl_test exited with error and we never saw a success message
error_found = True
finally:
try:
with contextlib.suppress(OSError):
proc.kill()
except OSError:
pass
proc.wait()
if device_found:
# Allow the kernel to fully release the USB interface
+10 -12
View File
@@ -7,8 +7,6 @@ HackRF supports 1 MHz to 6 GHz frequency range.
from __future__ import annotations
from typing import Optional
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
@@ -33,7 +31,7 @@ class HackRFCommandBuilder(CommandBuilder):
"""Build SoapySDR device string for HackRF."""
if device.serial and device.serial != 'N/A':
return f'driver=hackrf,serial={device.serial}'
return f'driver=hackrf'
return 'driver=hackrf'
def _split_gain(self, gain: float) -> tuple[int, int]:
"""
@@ -59,10 +57,10 @@ class HackRFCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: Optional[int] = None,
squelch: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -99,7 +97,7 @@ class HackRFCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -129,8 +127,8 @@ class HackRFCommandBuilder(CommandBuilder):
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -161,7 +159,7 @@ class HackRFCommandBuilder(CommandBuilder):
def build_ais_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110
) -> list[str]:
@@ -193,8 +191,8 @@ class HackRFCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
+10 -12
View File
@@ -7,8 +7,6 @@ LimeSDR supports 100 kHz to 3.8 GHz frequency range.
from __future__ import annotations
from typing import Optional
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
@@ -33,17 +31,17 @@ class LimeSDRCommandBuilder(CommandBuilder):
"""Build SoapySDR device string for LimeSDR."""
if device.serial and device.serial != 'N/A':
return f'driver=lime,serial={device.serial}'
return f'driver=lime'
return 'driver=lime'
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: Optional[int] = None,
squelch: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -78,7 +76,7 @@ class LimeSDRCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -108,8 +106,8 @@ class LimeSDRCommandBuilder(CommandBuilder):
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -140,7 +138,7 @@ class LimeSDRCommandBuilder(CommandBuilder):
def build_ais_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110
) -> list[str]:
@@ -170,8 +168,8 @@ class LimeSDRCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
+13 -13
View File
@@ -10,10 +10,10 @@ from __future__ import annotations
import logging
import re
import subprocess
from typing import Optional
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr')
@@ -46,7 +46,7 @@ def _rtl_tool_supports_bias_t(tool_path: str) -> bool:
return False
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
@@ -106,12 +106,12 @@ class RTLSDRCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: Optional[int] = None,
squelch: int | None = None,
bias_t: bool = False,
direct_sampling: Optional[int] = None,
direct_sampling: int | None = None,
) -> list[str]:
"""
Build rtl_fm command for FM demodulation.
@@ -163,7 +163,7 @@ class RTLSDRCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -208,8 +208,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -247,7 +247,7 @@ class RTLSDRCommandBuilder(CommandBuilder):
def build_ais_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110
) -> list[str]:
@@ -283,8 +283,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
+9 -11
View File
@@ -7,8 +7,6 @@ SDRPlay RSP devices support 1 kHz to 2 GHz frequency range.
from __future__ import annotations
from typing import Optional
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
@@ -41,10 +39,10 @@ class SDRPlayCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: Optional[int] = None,
squelch: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -80,7 +78,7 @@ class SDRPlayCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -110,8 +108,8 @@ class SDRPlayCommandBuilder(CommandBuilder):
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False
) -> list[str]:
"""
@@ -139,7 +137,7 @@ class SDRPlayCommandBuilder(CommandBuilder):
def build_ais_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110
) -> list[str]:
@@ -171,8 +169,8 @@ class SDRPlayCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
+2 -2
View File
@@ -240,9 +240,9 @@ def get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
Returns:
SDRCapabilities for the specified type
"""
from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder
builders = {
SDRType.RTL_SDR: RTLSDRCommandBuilder,
+31 -35
View File
@@ -12,8 +12,6 @@ from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
# =============================================================================
# Confidence Levels
@@ -41,7 +39,7 @@ class SignalTypeDefinition:
# Optional modulation hints (if provided, boosts confidence)
modulation_hints: list[str] = field(default_factory=list)
# Optional bandwidth range (min_hz, max_hz) - if provided, used for scoring
bandwidth_range: Optional[tuple[int, int]] = None
bandwidth_range: tuple[int, int] | None = None
# Base score for frequency match
base_score: int = 10
# Is this a burst/telemetry type signal?
@@ -414,12 +412,12 @@ class SignalGuessingEngine:
def guess_signal_type(
self,
frequency_hz: int,
modulation: Optional[str] = None,
bandwidth_hz: Optional[int] = None,
duration_ms: Optional[int] = None,
repetition_count: Optional[int] = None,
rssi_dbm: Optional[float] = None,
region: Optional[str] = None,
modulation: str | None = None,
bandwidth_hz: int | None = None,
duration_ms: int | None = None,
repetition_count: int | None = None,
rssi_dbm: float | None = None,
region: str | None = None,
) -> SignalGuessResult:
"""
Guess the signal type based on detection parameters.
@@ -523,10 +521,10 @@ class SignalGuessingEngine:
self,
signal_type: SignalTypeDefinition,
frequency_hz: int,
modulation: Optional[str],
bandwidth_hz: Optional[int],
duration_ms: Optional[int],
repetition_count: Optional[int],
modulation: str | None,
bandwidth_hz: int | None,
duration_ms: int | None,
repetition_count: int | None,
region: str,
) -> int:
"""Calculate score for a signal type match."""
@@ -580,8 +578,8 @@ class SignalGuessingEngine:
primary_score: int,
all_scores: dict[str, int],
sorted_labels: list[str],
modulation: Optional[str],
bandwidth_hz: Optional[int],
modulation: str | None,
bandwidth_hz: int | None,
) -> Confidence:
"""Calculate confidence level based on scores and data quality."""
@@ -603,9 +601,7 @@ class SignalGuessingEngine:
if primary_score >= 18 and margin >= 5:
return Confidence.HIGH
elif primary_score >= 14 and margin >= 3:
return Confidence.MEDIUM
elif primary_score >= 12 and margin >= 2:
elif primary_score >= 14 and margin >= 3 or primary_score >= 12 and margin >= 2:
return Confidence.MEDIUM
return Confidence.LOW
@@ -636,10 +632,10 @@ class SignalGuessingEngine:
signal_type: SignalTypeDefinition,
confidence: Confidence,
frequency_hz: int,
modulation: Optional[str],
bandwidth_hz: Optional[int],
duration_ms: Optional[int],
repetition_count: Optional[int],
modulation: str | None,
bandwidth_hz: int | None,
duration_ms: int | None,
repetition_count: int | None,
) -> str:
"""Build a hedged, client-safe explanation."""
freq_mhz = frequency_hz / 1_000_000
@@ -676,7 +672,7 @@ class SignalGuessingEngine:
def _build_unknown_explanation(
self,
frequency_hz: int,
modulation: Optional[str],
modulation: str | None,
) -> str:
"""Build explanation for unknown signal."""
freq_mhz = frequency_hz / 1_000_000
@@ -693,7 +689,7 @@ class SignalGuessingEngine:
def get_frequency_allocations(
self,
frequency_hz: int,
region: Optional[str] = None,
region: str | None = None,
) -> list[str]:
"""
Get all possible allocations for a frequency.
@@ -720,7 +716,7 @@ class SignalGuessingEngine:
# =============================================================================
# Default engine instance
_default_engine: Optional[SignalGuessingEngine] = None
_default_engine: SignalGuessingEngine | None = None
def get_engine(region: str = "UK/EU") -> SignalGuessingEngine:
@@ -733,11 +729,11 @@ def get_engine(region: str = "UK/EU") -> SignalGuessingEngine:
def guess_signal_type(
frequency_hz: int,
modulation: Optional[str] = None,
bandwidth_hz: Optional[int] = None,
duration_ms: Optional[int] = None,
repetition_count: Optional[int] = None,
rssi_dbm: Optional[float] = None,
modulation: str | None = None,
bandwidth_hz: int | None = None,
duration_ms: int | None = None,
repetition_count: int | None = None,
rssi_dbm: float | None = None,
region: str = "UK/EU",
) -> SignalGuessResult:
"""
@@ -759,11 +755,11 @@ def guess_signal_type(
def guess_signal_type_dict(
frequency_hz: int,
modulation: Optional[str] = None,
bandwidth_hz: Optional[int] = None,
duration_ms: Optional[int] = None,
repetition_count: Optional[int] = None,
rssi_dbm: Optional[float] = None,
modulation: str | None = None,
bandwidth_hz: int | None = None,
duration_ms: int | None = None,
repetition_count: int | None = None,
rssi_dbm: float | None = None,
region: str = "UK/EU",
) -> dict:
"""
+57 -51
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import contextlib
import json
import queue
import threading
import time
from collections.abc import Generator
from dataclasses import dataclass, field
from typing import Any, Callable, Generator
from typing import Any, Callable
@dataclass
@@ -29,6 +31,12 @@ def _run_fanout(channel: _QueueFanoutChannel) -> None:
idle_drain_batch = 512
while True:
src = channel.source_queue
if src is None:
# Source queue was cleared (e.g. during interpreter shutdown).
time.sleep(0.5)
continue
with channel.lock:
subscribers = tuple(channel.subscribers)
@@ -39,7 +47,7 @@ def _run_fanout(channel: _QueueFanoutChannel) -> None:
drained = 0
for _ in range(idle_drain_batch):
try:
channel.source_queue.get_nowait()
src.get_nowait()
drained += 1
except queue.Empty:
break
@@ -49,7 +57,7 @@ def _run_fanout(channel: _QueueFanoutChannel) -> None:
continue
try:
msg = channel.source_queue.get(timeout=channel.source_timeout)
msg = src.get(timeout=channel.source_timeout)
except queue.Empty:
continue
@@ -153,10 +161,8 @@ def sse_stream_fanout(
msg = subscriber.get(timeout=timeout)
last_keepalive = time.time()
if on_message and isinstance(msg, dict):
try:
with contextlib.suppress(Exception):
on_message(msg)
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
@@ -174,7 +180,7 @@ def sse_stream(
stop_check: Callable[[], bool] | None = None,
channel_key: str | None = None,
) -> Generator[str, None, None]:
"""
"""
Generate SSE stream from a queue.
Args:
@@ -195,47 +201,47 @@ def sse_stream(
keepalive_interval=keepalive_interval,
stop_check=stop_check,
)
def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str:
"""
Format data as SSE message.
Args:
data: Data to send (will be JSON encoded if dict)
event: Optional event name
Returns:
SSE formatted string
"""
if isinstance(data, dict):
data = json.dumps(data)
lines = []
if event:
lines.append(f"event: {event}")
lines.append(f"data: {data}")
lines.append("")
lines.append("")
return '\n'.join(lines)
def clear_queue(q: queue.Queue) -> int:
"""
Clear all items from a queue.
Args:
q: Queue to clear
Returns:
Number of items cleared
"""
count = 0
while True:
try:
q.get_nowait()
count += 1
except queue.Empty:
break
return count
def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str:
"""
Format data as SSE message.
Args:
data: Data to send (will be JSON encoded if dict)
event: Optional event name
Returns:
SSE formatted string
"""
if isinstance(data, dict):
data = json.dumps(data)
lines = []
if event:
lines.append(f"event: {event}")
lines.append(f"data: {data}")
lines.append("")
lines.append("")
return '\n'.join(lines)
def clear_queue(q: queue.Queue) -> int:
"""
Clear all items from a queue.
Args:
q: Queue to clear
Returns:
Number of items cleared
"""
count = 0
while True:
try:
q.get_nowait()
count += 1
except queue.Empty:
break
return count
+23 -40
View File
@@ -7,8 +7,9 @@ sweeps via hackrf_sweep.
from __future__ import annotations
import json
import contextlib
import hashlib
import json
import os
import queue
import shutil
@@ -21,23 +22,21 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import BinaryIO, Callable
import numpy as np
from utils.dependencies import get_tool_path
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
from utils.constants import (
SUBGHZ_TX_ALLOWED_BANDS,
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ,
SUBGHZ_LNA_GAIN_MIN,
import numpy as np
from utils.constants import (
SUBGHZ_LNA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MIN,
SUBGHZ_VGA_GAIN_MAX,
SUBGHZ_TX_VGA_GAIN_MIN,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_LNA_GAIN_MIN,
SUBGHZ_TX_ALLOWED_BANDS,
SUBGHZ_TX_MAX_DURATION,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_TX_VGA_GAIN_MIN,
SUBGHZ_VGA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MIN,
)
from utils.dependencies import get_tool_path
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
logger = get_logger('intercept.subghz')
@@ -1020,10 +1019,8 @@ class SubGhzManager:
len(self._rx_bursts),
)
finally:
try:
with contextlib.suppress(OSError):
file_handle.close()
except OSError:
pass
with self._lock:
if self._rx_file_handle is file_handle:
self._rx_file_handle = None
@@ -1162,10 +1159,8 @@ class SubGhzManager:
thread_to_join.join(timeout=2.0)
if file_handle:
try:
with contextlib.suppress(OSError):
file_handle.close()
except OSError:
pass
with self._lock:
if self._rx_file_handle is file_handle:
self._rx_file_handle = None
@@ -1580,22 +1575,16 @@ class SubGhzManager:
except queue.Full:
# Drop oldest chunk to prevent backpressure
logger.debug("IQ queue full, dropping oldest chunk")
try:
with contextlib.suppress(queue.Empty):
iq_queue.get_nowait()
except queue.Empty:
pass
try:
with contextlib.suppress(queue.Full):
iq_queue.put_nowait(data)
except queue.Full:
pass
except OSError:
pass
# Signal writer to stop
try:
with contextlib.suppress(queue.Full):
iq_queue.put_nowait(None)
except queue.Full:
pass
def _rtl433_writer(
self,
@@ -1738,10 +1727,8 @@ class SubGhzManager:
'duration_ms': int(duration * 1000),
'peak_level': int(burst_peak),
})
try:
with contextlib.suppress(OSError):
dst.close()
except OSError:
pass
def _read_decode_output(self) -> None:
process = self._decode_process
@@ -2327,7 +2314,7 @@ class SubGhzManager:
if len(parts) < 7:
continue
hz_low = float(parts[2].strip())
hz_high = float(parts[3].strip())
float(parts[3].strip())
hz_bin_width = float(parts[4].strip())
powers = [float(p.strip()) for p in parts[6:] if p.strip()]
if not powers or hz_bin_width <= 0:
@@ -2551,9 +2538,7 @@ class SubGhzManager:
continue
best_peak = float(best_burst.get('peak_level', 0.0) or 0.0)
cur_peak = float(burst.get('peak_level', 0.0) or 0.0)
if cur_peak > best_peak:
best_burst = burst
elif cur_peak == best_peak and dur > float(best_burst.get('duration_seconds', 0.0) or 0.0):
if cur_peak > best_peak or cur_peak == best_peak and dur > float(best_burst.get('duration_seconds', 0.0) or 0.0):
best_burst = burst
if best_burst:
@@ -2832,10 +2817,8 @@ class SubGhzManager:
sweep_thread.join(timeout=1.5)
if rx_file_handle:
try:
with contextlib.suppress(OSError):
rx_file_handle.close()
except OSError:
pass
# Global singleton
+22 -23
View File
@@ -9,10 +9,9 @@ signal strength measurements from multiple agents at known positions.
from __future__ import annotations
import math
import logging
import math
from dataclasses import dataclass, field
from typing import List, Tuple, Optional
from datetime import datetime, timezone
logger = logging.getLogger('intercept.trilateration')
@@ -30,7 +29,7 @@ class AgentObservation:
agent_lon: float
rssi: float # dBm
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
frequency_mhz: Optional[float] = None # For frequency-dependent path loss
frequency_mhz: float | None = None # For frequency-dependent path loss
@dataclass
@@ -41,7 +40,7 @@ class LocationEstimate:
accuracy_meters: float # Estimated accuracy radius
confidence: float # 0.0 to 1.0
num_observations: int
observations: List[AgentObservation] = field(default_factory=list)
observations: list[AgentObservation] = field(default_factory=list)
method: str = "multilateration"
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -97,8 +96,8 @@ class PathLossModel:
def __init__(
self,
environment: str = 'outdoor',
path_loss_exponent: Optional[float] = None,
reference_rssi: Optional[float] = None
path_loss_exponent: float | None = None,
reference_rssi: float | None = None
):
"""
Initialize path loss model.
@@ -115,7 +114,7 @@ class PathLossModel:
def rssi_to_distance(
self,
rssi: float,
frequency_mhz: Optional[float] = None
frequency_mhz: float | None = None
) -> float:
"""
Convert RSSI to estimated distance in meters.
@@ -150,7 +149,7 @@ class PathLossModel:
def distance_to_rssi(
self,
distance: float,
frequency_mhz: Optional[float] = None
frequency_mhz: float | None = None
) -> float:
"""
Estimate RSSI at a given distance (inverse of rssi_to_distance).
@@ -195,7 +194,7 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl
return R * c
def meters_to_degrees(meters: float, latitude: float) -> Tuple[float, float]:
def meters_to_degrees(meters: float, latitude: float) -> tuple[float, float]:
"""
Convert meters to approximate degrees at a given latitude.
@@ -210,7 +209,7 @@ def meters_to_degrees(meters: float, latitude: float) -> Tuple[float, float]:
return lat_deg, lon_deg
def offset_position(lat: float, lon: float, north_m: float, east_m: float) -> Tuple[float, float]:
def offset_position(lat: float, lon: float, north_m: float, east_m: float) -> tuple[float, float]:
"""
Offset a GPS position by meters north and east.
@@ -238,7 +237,7 @@ class Trilateration:
def __init__(
self,
path_loss_model: Optional[PathLossModel] = None,
path_loss_model: PathLossModel | None = None,
min_observations: int = 2,
max_iterations: int = 100,
convergence_threshold: float = 0.1 # meters
@@ -259,8 +258,8 @@ class Trilateration:
def estimate_location(
self,
observations: List[AgentObservation]
) -> Optional[LocationEstimate]:
observations: list[AgentObservation]
) -> LocationEstimate | None:
"""
Estimate device location from multiple agent observations.
@@ -395,7 +394,7 @@ class DeviceLocationTracker:
def __init__(
self,
trilateration: Optional[Trilateration] = None,
trilateration: Trilateration | None = None,
observation_window_seconds: float = 60.0,
min_observations: int = 2
):
@@ -412,7 +411,7 @@ class DeviceLocationTracker:
self.min_observations = min_observations
# device_id -> list of AgentObservation
self.observations: dict[str, List[AgentObservation]] = {}
self.observations: dict[str, list[AgentObservation]] = {}
# device_id -> latest LocationEstimate
self.locations: dict[str, LocationEstimate] = {}
@@ -424,9 +423,9 @@ class DeviceLocationTracker:
agent_lat: float,
agent_lon: float,
rssi: float,
frequency_mhz: Optional[float] = None,
timestamp: Optional[datetime] = None
) -> Optional[LocationEstimate]:
frequency_mhz: float | None = None,
timestamp: datetime | None = None
) -> LocationEstimate | None:
"""
Add an observation and potentially update location estimate.
@@ -472,7 +471,7 @@ class DeviceLocationTracker:
if obs.timestamp.timestamp() > cutoff
]
def _update_location(self, device_id: str) -> Optional[LocationEstimate]:
def _update_location(self, device_id: str) -> LocationEstimate | None:
"""Compute location estimate from current observations."""
obs_list = self.observations.get(device_id, [])
@@ -494,7 +493,7 @@ class DeviceLocationTracker:
return estimate
def get_location(self, device_id: str) -> Optional[LocationEstimate]:
def get_location(self, device_id: str) -> LocationEstimate | None:
"""Get the latest location estimate for a device."""
return self.locations.get(device_id)
@@ -507,7 +506,7 @@ class DeviceLocationTracker:
lat: float,
lon: float,
radius_meters: float
) -> List[Tuple[str, LocationEstimate]]:
) -> list[tuple[str, LocationEstimate]]:
"""Find all tracked devices within radius of a point."""
results = []
for device_id, estimate in self.locations.items():
@@ -527,9 +526,9 @@ class DeviceLocationTracker:
# =============================================================================
def estimate_location_from_observations(
observations: List[dict],
observations: list[dict],
environment: str = 'outdoor'
) -> Optional[dict]:
) -> dict | None:
"""
Convenience function to estimate location from a list of observation dicts.
+36 -37
View File
@@ -24,7 +24,7 @@ import subprocess
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Optional
from typing import Any
logger = logging.getLogger('intercept.tscm.advanced')
@@ -893,10 +893,10 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None:
class DeviceObservation:
"""A single observation of a device."""
timestamp: datetime
rssi: Optional[int] = None
rssi: int | None = None
present: bool = True
channel: Optional[int] = None
frequency: Optional[float] = None
channel: int | None = None
frequency: float | None = None
attributes: dict = field(default_factory=dict)
@@ -910,21 +910,21 @@ class DeviceTimeline:
"""
identifier: str
protocol: str
name: Optional[str] = None
name: str | None = None
# Observation history (time-bucketed)
observations: list[DeviceObservation] = field(default_factory=list)
# Computed metrics
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
first_seen: datetime | None = None
last_seen: datetime | None = None
total_observations: int = 0
presence_ratio: float = 0.0 # % of time device was present
# Signal metrics
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_mean: Optional[float] = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_mean: float | None = None
rssi_stability: float = 0.0 # 0-1, higher = more stable
# Movement assessment
@@ -991,17 +991,17 @@ class TimelineManager:
self.bucket_seconds = bucket_seconds
self.max_observations = max_observations
self.timelines: dict[str, DeviceTimeline] = {}
self._meeting_windows: list[tuple[datetime, Optional[datetime]]] = []
self._meeting_windows: list[tuple[datetime, datetime | None]] = []
def add_observation(
self,
identifier: str,
protocol: str,
rssi: Optional[int] = None,
channel: Optional[int] = None,
frequency: Optional[float] = None,
name: Optional[str] = None,
attributes: Optional[dict] = None
rssi: int | None = None,
channel: int | None = None,
frequency: float | None = None,
name: str | None = None,
attributes: dict | None = None
) -> None:
"""Add an observation for a device."""
key = f"{protocol}:{identifier.upper()}"
@@ -1080,7 +1080,7 @@ class TimelineManager:
return True
return False
def compute_metrics(self, identifier: str, protocol: str) -> Optional[DeviceTimeline]:
def compute_metrics(self, identifier: str, protocol: str) -> DeviceTimeline | None:
"""Compute all metrics for a device timeline."""
key = f"{protocol}:{identifier.upper()}"
if key not in self.timelines:
@@ -1125,7 +1125,7 @@ class TimelineManager:
return timeline
def get_timeline(self, identifier: str, protocol: str) -> Optional[DeviceTimeline]:
def get_timeline(self, identifier: str, protocol: str) -> DeviceTimeline | None:
"""Get computed timeline for a device."""
return self.compute_metrics(identifier, protocol)
@@ -1150,9 +1150,9 @@ class MeetingWindowSummary:
and applies meeting-window scoring modifiers.
"""
meeting_id: int
name: Optional[str] = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
name: str | None = None
start_time: datetime | None = None
end_time: datetime | None = None
duration_minutes: float = 0.0
# Devices first seen during meeting (high interest)
@@ -1431,7 +1431,7 @@ class WiFiAdvancedDetector:
self.indicators.extend(indicators)
return indicators
def add_probe_request(self, frame: dict) -> Optional[WiFiAdvancedIndicator]:
def add_probe_request(self, frame: dict) -> WiFiAdvancedIndicator | None:
"""
Record a probe request frame (requires monitor mode).
@@ -1475,7 +1475,7 @@ class WiFiAdvancedDetector:
details={
'ssid': ssid,
'probe_count': len(recent_probes),
'source_macs': list(set(p['src_mac'] for p in recent_probes)),
'source_macs': list({p['src_mac'] for p in recent_probes}),
'pattern': 'Multiple probe requests for potentially sensitive network',
},
requires_monitor_mode=True,
@@ -1485,7 +1485,7 @@ class WiFiAdvancedDetector:
return None
def add_deauth_frame(self, frame: dict) -> Optional[WiFiAdvancedIndicator]:
def add_deauth_frame(self, frame: dict) -> WiFiAdvancedIndicator | None:
"""
Record a deauthentication frame (requires monitor mode).
@@ -1523,7 +1523,7 @@ class WiFiAdvancedDetector:
'deauth_count': len(recent_deauths),
'time_window_seconds': 10,
'targeted_bssid': bssid if targeting_bssid else None,
'unique_sources': len(set(d['src_mac'] for d in recent_deauths)),
'unique_sources': len({d['src_mac'] for d in recent_deauths}),
'pattern': 'Abnormal deauthentication frame volume',
},
requires_monitor_mode=True,
@@ -1574,7 +1574,7 @@ class BLERiskExplanation:
and recommended actions.
"""
identifier: str
name: Optional[str] = None
name: str | None = None
# Risk assessment
risk_level: str = 'informational'
@@ -1588,7 +1588,7 @@ class BLERiskExplanation:
# Tracker detection
is_tracker: bool = False
tracker_type: Optional[str] = None
tracker_type: str | None = None
tracker_explanation: str = ''
# Meeting correlation
@@ -1686,7 +1686,7 @@ def estimate_ble_proximity(rssi: int) -> tuple[BLEProximity, str, str]:
def generate_ble_risk_explanation(
device: dict,
profile: Optional[dict] = None,
profile: dict | None = None,
is_during_meeting: bool = False
) -> BLERiskExplanation:
"""
@@ -1722,7 +1722,7 @@ def generate_ble_risk_explanation(
explanation.proximity_explanation = "Could not parse RSSI value"
# Tracker detection with explanation
tracker_info = device.get('tracker_type') or device.get('is_tracker')
device.get('tracker_type') or device.get('is_tracker')
if device.get('is_airtag'):
explanation.is_tracker = True
explanation.tracker_type = 'Apple AirTag'
@@ -1902,7 +1902,7 @@ class PlaybookStep:
step_number: int
action: str
details: str
safety_note: Optional[str] = None
safety_note: str | None = None
@dataclass
@@ -2145,8 +2145,8 @@ PLAYBOOKS = {
def get_playbook_for_finding(
risk_level: str,
finding_type: Optional[str] = None,
indicators: Optional[list[dict]] = None
finding_type: str | None = None,
indicators: list[dict] | None = None
) -> OperatorPlaybook:
"""
Get appropriate playbook for a finding.
@@ -2166,9 +2166,8 @@ def get_playbook_for_finding(
# Check indicators for tracker
if indicators:
tracker_types = ['airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker']
if any(i.get('type') in tracker_types for i in indicators):
if risk_level == 'high_interest':
return PLAYBOOKS['high_interest_tracker']
if any(i.get('type') in tracker_types for i in indicators) and risk_level == 'high_interest':
return PLAYBOOKS['high_interest_tracker']
# Return based on risk level
if risk_level == 'high_interest':
@@ -2207,8 +2206,8 @@ def attach_playbook_to_finding(finding: dict) -> dict:
# Global Instance Management
# =============================================================================
_timeline_manager: Optional[TimelineManager] = None
_wifi_detector: Optional[WiFiAdvancedDetector] = None
_timeline_manager: TimelineManager | None = None
_wifi_detector: WiFiAdvancedDetector | None = None
def get_timeline_manager() -> TimelineManager:
-3
View File
@@ -9,12 +9,10 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from utils.database import (
create_tscm_baseline,
get_active_tscm_baseline,
get_tscm_baseline,
update_tscm_baseline,
)
@@ -107,7 +105,6 @@ class BaselineRecorder:
f"{summary['bt_count']} BT, {summary['rf_count']} RF"
)
baseline_id = self.current_baseline_id
self.current_baseline_id = None
return summary
+2 -4
View File
@@ -180,9 +180,7 @@ class BLEScanner:
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})')
# Handle various data types safely
try:
if isinstance(data, (bytes, bytearray)):
ble_device.manufacturer_data = bytes(data)
elif isinstance(data, (list, tuple)):
if isinstance(data, (bytes, bytearray, list, tuple)):
ble_device.manufacturer_data = bytes(data)
elif isinstance(data, str):
ble_device.manufacturer_data = bytes.fromhex(data)
@@ -237,7 +235,7 @@ class BLEScanner:
try:
# Try to get existing event loop
try:
loop = asyncio.get_running_loop()
asyncio.get_running_loop()
# We're in an async context, can't use run()
future = asyncio.ensure_future(self.scan_async(duration))
return asyncio.get_event_loop().run_until_complete(future)
+23 -29
View File
@@ -10,11 +10,11 @@ Findings indicate anomalies and indicators, not confirmed surveillance devices.
from __future__ import annotations
import contextlib
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional
logger = logging.getLogger('intercept.tscm.correlation')
@@ -119,36 +119,36 @@ class DeviceProfile:
protocol: str # 'bluetooth', 'wifi', 'rf'
# Device info
name: Optional[str] = None
manufacturer: Optional[str] = None
device_type: Optional[str] = None
tracker_type: Optional[str] = None
tracker_name: Optional[str] = None
tracker_confidence: Optional[str] = None
tracker_confidence_score: Optional[float] = None
name: str | None = None
manufacturer: str | None = None
device_type: str | None = None
tracker_type: str | None = None
tracker_name: str | None = None
tracker_confidence: str | None = None
tracker_confidence_score: float | None = None
tracker_evidence: list[str] = field(default_factory=list)
# Bluetooth-specific
services: list[str] = field(default_factory=list)
company_id: Optional[int] = None
advertising_interval: Optional[int] = None
company_id: int | None = None
advertising_interval: int | None = None
# Wi-Fi-specific
ssid: Optional[str] = None
channel: Optional[int] = None
encryption: Optional[str] = None
beacon_interval: Optional[int] = None
ssid: str | None = None
channel: int | None = None
encryption: str | None = None
beacon_interval: int | None = None
is_hidden: bool = False
# RF-specific
frequency: Optional[float] = None
bandwidth: Optional[float] = None
modulation: Optional[str] = None
frequency: float | None = None
bandwidth: float | None = None
modulation: str | None = None
# Common measurements
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
first_seen: datetime | None = None
last_seen: datetime | None = None
detection_count: int = 0
# Behavioral analysis
@@ -163,7 +163,7 @@ class DeviceProfile:
confidence: float = 0.0
recommended_action: str = 'monitor'
known_device: bool = False
known_device_name: Optional[str] = None
known_device_name: str | None = None
score_modifier: int = 0
def add_rssi_sample(self, rssi: int) -> None:
@@ -466,10 +466,8 @@ class CorrelationEngine:
# Add RSSI sample
rssi = device.get('rssi', device.get('signal'))
if rssi:
try:
with contextlib.suppress(ValueError, TypeError):
profile.add_rssi_sample(int(rssi))
except (ValueError, TypeError):
pass
# Clear previous indicators for fresh analysis
profile.indicators = []
@@ -789,10 +787,8 @@ class CorrelationEngine:
# Add RSSI sample
rssi = device.get('rssi', device.get('power', device.get('signal')))
if rssi:
try:
with contextlib.suppress(ValueError, TypeError):
profile.add_rssi_sample(int(rssi))
except (ValueError, TypeError):
pass
# Clear previous indicators
profile.indicators = []
@@ -937,10 +933,8 @@ class CorrelationEngine:
# Add power sample
power = signal.get('power', signal.get('level'))
if power:
try:
with contextlib.suppress(ValueError, TypeError):
profile.add_rssi_sample(int(float(power)))
except (ValueError, TypeError):
pass
# Clear previous indicators
profile.indicators = []
+1 -7
View File
@@ -9,21 +9,15 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from data.tscm_frequencies import (
BLE_TRACKER_SIGNATURES,
THREAT_TYPES,
WIFI_CAMERA_PATTERNS,
get_frequency_risk,
get_threat_severity,
is_known_tracker,
is_potential_camera,
)
from utils.tscm.signal_classification import (
classify_signal_strength,
get_signal_strength_info,
SignalStrength,
)
logger = logging.getLogger('intercept.tscm.detector')
@@ -337,7 +331,7 @@ class ThreatDetector:
"""
frequency = signal.get('frequency', 0)
power = signal.get('power', signal.get('level', -100))
band = signal.get('band', '')
signal.get('band', '')
reasons = []
classification = 'informational'
+48 -52
View File
@@ -26,13 +26,11 @@ from __future__ import annotations
import hashlib
import logging
import math
import statistics
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional
logger = logging.getLogger('intercept.tscm.device_identity')
@@ -119,18 +117,18 @@ class BLEObservation:
timestamp: datetime
addr: str # MAC-like address
addr_type: AddressType = AddressType.UNKNOWN
rssi: Optional[int] = None
tx_power: Optional[int] = None
rssi: int | None = None
tx_power: int | None = None
adv_type: AdvType = AdvType.UNKNOWN
adv_flags: Optional[int] = None
manufacturer_id: Optional[int] = None
manufacturer_data: Optional[bytes] = None
adv_flags: int | None = None
manufacturer_id: int | None = None
manufacturer_data: bytes | None = None
service_uuids: list[str] = field(default_factory=list)
service_data: Optional[bytes] = None
local_name: Optional[str] = None
appearance: Optional[int] = None
packet_length: Optional[int] = None
phy: Optional[str] = None
service_data: bytes | None = None
local_name: str | None = None
appearance: int | None = None
packet_length: int | None = None
phy: str | None = None
def __post_init__(self):
if isinstance(self.addr_type, str):
@@ -202,26 +200,26 @@ class WifiObservation:
"""Single WiFi frame observation."""
timestamp: datetime
src_mac: str
dst_mac: Optional[str] = None
bssid: Optional[str] = None
ssid: Optional[str] = None
dst_mac: str | None = None
bssid: str | None = None
ssid: str | None = None
frame_type: WifiFrameType = WifiFrameType.UNKNOWN
rssi: Optional[int] = None
channel: Optional[int] = None
bandwidth: Optional[int] = None # 20/40/80/160
encryption: Optional[str] = None
beacon_interval: Optional[int] = None
capabilities: Optional[int] = None
rssi: int | None = None
channel: int | None = None
bandwidth: int | None = None # 20/40/80/160
encryption: str | None = None
beacon_interval: int | None = None
capabilities: int | None = None
supported_rates: list[float] = field(default_factory=list)
extended_rates: list[float] = field(default_factory=list)
ht_capable: bool = False
vht_capable: bool = False
he_capable: bool = False
ht_capabilities: Optional[int] = None
vht_capabilities: Optional[int] = None
ht_capabilities: int | None = None
vht_capabilities: int | None = None
vendor_ies: list[tuple[str, int]] = field(default_factory=list) # (OUI, length)
wps_present: bool = False
sequence_number: Optional[int] = None
sequence_number: int | None = None
probed_ssids: list[str] = field(default_factory=list)
def __post_init__(self):
@@ -263,7 +261,7 @@ class WifiObservation:
# Vendor IE fingerprint (OUIs only, not content)
if self.vendor_ies:
ouis = sorted(set(oui for oui, _ in self.vendor_ies))
ouis = sorted({oui for oui, _ in self.vendor_ies})
components.append(f"vie:{','.join(ouis)}")
if self.capabilities is not None:
@@ -301,7 +299,7 @@ class DeviceSession:
first_seen: datetime
last_seen: datetime
observations: list = field(default_factory=list)
primary_mac: Optional[str] = None
primary_mac: str | None = None
observed_macs: set[str] = field(default_factory=set)
fingerprint_hashes: set[str] = field(default_factory=set)
@@ -341,7 +339,7 @@ class DeviceSession:
"""Get session duration."""
return self.last_seen - self.first_seen
def get_mean_rssi(self) -> Optional[float]:
def get_mean_rssi(self) -> float | None:
"""Get mean RSSI across session."""
if not self.rssi_samples:
return None
@@ -362,7 +360,7 @@ class DeviceSession:
except statistics.StatisticsError:
return 0.0
def get_mean_interval(self) -> Optional[float]:
def get_mean_interval(self) -> float | None:
"""Get mean advertising/probing interval."""
if not self.observation_intervals:
return None
@@ -427,10 +425,10 @@ class DeviceCluster:
link_evidence: list[dict] = field(default_factory=list)
# Best available identifiers
best_name: Optional[str] = None
manufacturer_id: Optional[int] = None
manufacturer_name: Optional[str] = None
device_type: Optional[str] = None
best_name: str | None = None
manufacturer_id: int | None = None
manufacturer_name: str | None = None
device_type: str | None = None
# TSCM risk assessment
risk_level: RiskLevel = RiskLevel.INFORMATIONAL
@@ -439,8 +437,8 @@ class DeviceCluster:
# Behavioral profile
total_observations: int = 0
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
first_seen: datetime | None = None
last_seen: datetime | None = None
presence_ratio: float = 0.0 # % of monitoring period device was present
def add_session(self, session: DeviceSession, link_reason: str,
@@ -532,8 +530,8 @@ def jaccard_similarity(set1: set, set2: set) -> float:
return intersection / union if union > 0 else 0.0
def manufacturer_data_similarity(data1: Optional[bytes],
data2: Optional[bytes]) -> float:
def manufacturer_data_similarity(data1: bytes | None,
data2: bytes | None) -> float:
"""
Calculate similarity between manufacturer data blobs.
@@ -626,7 +624,7 @@ def timing_pattern_similarity(intervals1: list[float],
return 0.7 * ratio + 0.3 * max(0, cv_sim)
def name_similarity(name1: Optional[str], name2: Optional[str]) -> float:
def name_similarity(name1: str | None, name2: str | None) -> float:
"""Calculate similarity between device names."""
if not name1 or not name2:
return 0.0
@@ -673,8 +671,8 @@ class DeviceIdentityEngine:
self._cluster_counter = 0
# Monitoring period for presence calculation
self.monitoring_start: Optional[datetime] = None
self.monitoring_end: Optional[datetime] = None
self.monitoring_start: datetime | None = None
self.monitoring_end: datetime | None = None
def _generate_session_id(self, protocol: str) -> str:
"""Generate unique session ID."""
@@ -714,9 +712,8 @@ class DeviceIdentityEngine:
# Update fingerprint index
fp = obs.compute_fingerprint_hash()
if fp:
if session.session_id not in self._fingerprint_to_sessions[fp]:
self._fingerprint_to_sessions[fp].append(session.session_id)
if fp and session.session_id not in self._fingerprint_to_sessions[fp]:
self._fingerprint_to_sessions[fp].append(session.session_id)
return session
@@ -757,9 +754,8 @@ class DeviceIdentityEngine:
# Update fingerprint index
fp = obs.compute_fingerprint_hash()
if fp:
if session.session_id not in self._fingerprint_to_sessions[fp]:
self._fingerprint_to_sessions[fp].append(session.session_id)
if fp and session.session_id not in self._fingerprint_to_sessions[fp]:
self._fingerprint_to_sessions[fp].append(session.session_id)
return session
@@ -784,7 +780,7 @@ class DeviceIdentityEngine:
similarity = self._calculate_cluster_similarity(cluster, session)
cluster.add_session(
session,
link_reason=f"Fingerprint/behavioral match",
link_reason="Fingerprint/behavioral match",
link_confidence=similarity
)
else:
@@ -795,7 +791,7 @@ class DeviceIdentityEngine:
# Run risk assessment on the cluster
self._assess_cluster_risk(cluster)
def _find_matching_cluster(self, session: DeviceSession) -> Optional[DeviceCluster]:
def _find_matching_cluster(self, session: DeviceSession) -> DeviceCluster | None:
"""
Find an existing cluster that matches this session.
@@ -884,7 +880,7 @@ class DeviceIdentityEngine:
return weighted_sum / total_weight if total_weight > 0 else 0.0
def _get_cluster_manufacturer_data(self, cluster: DeviceCluster) -> Optional[bytes]:
def _get_cluster_manufacturer_data(self, cluster: DeviceCluster) -> bytes | None:
"""Get representative manufacturer data from cluster."""
for session in cluster.sessions:
for obs in session.observations:
@@ -892,7 +888,7 @@ class DeviceIdentityEngine:
return obs.manufacturer_data
return None
def _get_session_manufacturer_data(self, session: DeviceSession) -> Optional[bytes]:
def _get_session_manufacturer_data(self, session: DeviceSession) -> bytes | None:
"""Get manufacturer data from session."""
for obs in session.observations:
if hasattr(obs, 'manufacturer_data') and obs.manufacturer_data:
@@ -923,7 +919,7 @@ class DeviceIdentityEngine:
intervals.extend(session.observation_intervals)
return intervals
def _get_session_name(self, session: DeviceSession) -> Optional[str]:
def _get_session_name(self, session: DeviceSession) -> str | None:
"""Get device name from session."""
for obs in session.observations:
if hasattr(obs, 'local_name') and obs.local_name:
@@ -1140,7 +1136,7 @@ class DeviceIdentityEngine:
# =============================================================================
# Global engine instance
_identity_engine: Optional[DeviceIdentityEngine] = None
_identity_engine: DeviceIdentityEngine | None = None
def get_identity_engine() -> DeviceIdentityEngine:
@@ -1157,7 +1153,7 @@ def reset_identity_engine() -> None:
_identity_engine = DeviceIdentityEngine()
def _convert_to_bytes(value) -> Optional[bytes]:
def _convert_to_bytes(value) -> bytes | None:
"""Convert various data types to bytes safely."""
if value is None:
return None
+35 -44
View File
@@ -13,21 +13,14 @@ from __future__ import annotations
import csv
import io
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from utils.tscm.signal_classification import (
SignalStrength,
ConfidenceLevel,
assess_signal,
classify_signal_strength,
describe_signal_for_report,
format_signal_for_dashboard,
generate_hedged_statement,
SIGNAL_ANALYSIS_DISCLAIMER,
assess_signal,
generate_hedged_statement,
)
logger = logging.getLogger('intercept.tscm.reports')
@@ -41,7 +34,7 @@ class ReportFinding:
"""A single finding for the report."""
identifier: str
protocol: str
name: Optional[str]
name: str | None
risk_level: str
risk_score: int
description: str
@@ -49,18 +42,18 @@ class ReportFinding:
recommended_action: str = ''
playbook_reference: str = ''
# Signal classification data
signal_strength: Optional[str] = None # minimal, weak, moderate, strong, very_strong
signal_confidence: Optional[str] = None # low, medium, high
signal_interpretation: Optional[str] = None
signal_strength: str | None = None # minimal, weak, moderate, strong, very_strong
signal_confidence: str | None = None # low, medium, high
signal_interpretation: str | None = None
signal_caveats: list[str] = field(default_factory=list)
@dataclass
class ReportMeetingSummary:
"""Meeting window summary for report."""
name: Optional[str]
name: str | None
start_time: str
end_time: Optional[str]
end_time: str | None
duration_minutes: float
devices_first_seen: int
behavior_changes: int
@@ -81,9 +74,9 @@ class TSCMReport:
sweep_type: str
# Location and context
location: Optional[str] = None
baseline_id: Optional[int] = None
baseline_name: Optional[str] = None
location: str | None = None
baseline_id: int | None = None
baseline_name: str | None = None
# Executive summary
executive_summary: str = ''
@@ -112,14 +105,14 @@ class TSCMReport:
missing_devices: int = 0
# Sweep duration
sweep_start: Optional[datetime] = None
sweep_end: Optional[datetime] = None
sweep_start: datetime | None = None
sweep_end: datetime | None = None
duration_minutes: float = 0.0
# Technical data (for annex only)
device_timelines: list[dict] = field(default_factory=list)
all_indicators: list[dict] = field(default_factory=list)
baseline_diff: Optional[dict] = None
baseline_diff: dict | None = None
correlation_data: list[dict] = field(default_factory=list)
@@ -613,15 +606,15 @@ class TSCMReportBuilder:
sweep_type='standard',
)
def set_sweep_type(self, sweep_type: str) -> 'TSCMReportBuilder':
def set_sweep_type(self, sweep_type: str) -> TSCMReportBuilder:
self.report.sweep_type = sweep_type
return self
def set_location(self, location: str) -> 'TSCMReportBuilder':
def set_location(self, location: str) -> TSCMReportBuilder:
self.report.location = location
return self
def set_baseline(self, baseline_id: int, baseline_name: str) -> 'TSCMReportBuilder':
def set_baseline(self, baseline_id: int, baseline_name: str) -> TSCMReportBuilder:
self.report.baseline_id = baseline_id
self.report.baseline_name = baseline_name
return self
@@ -629,8 +622,8 @@ class TSCMReportBuilder:
def set_sweep_times(
self,
start: datetime,
end: Optional[datetime] = None
) -> 'TSCMReportBuilder':
end: datetime | None = None
) -> TSCMReportBuilder:
self.report.sweep_start = start
self.report.sweep_end = end or datetime.now()
self.report.duration_minutes = (
@@ -638,12 +631,12 @@ class TSCMReportBuilder:
)
return self
def add_capabilities(self, capabilities: dict) -> 'TSCMReportBuilder':
def add_capabilities(self, capabilities: dict) -> TSCMReportBuilder:
self.report.capabilities = capabilities
self.report.limitations = capabilities.get('all_limitations', [])
return self
def add_finding(self, finding: ReportFinding) -> 'TSCMReportBuilder':
def add_finding(self, finding: ReportFinding) -> TSCMReportBuilder:
if finding.risk_level == 'high_interest':
self.report.high_interest_findings.append(finding)
elif finding.risk_level in ['review', 'needs_review']:
@@ -652,7 +645,7 @@ class TSCMReportBuilder:
self.report.informational_findings.append(finding)
return self
def add_findings_from_profiles(self, profiles: list[dict]) -> 'TSCMReportBuilder':
def add_findings_from_profiles(self, profiles: list[dict]) -> TSCMReportBuilder:
"""Add findings from correlation engine device profiles."""
for profile in profiles:
# Get signal classification data
@@ -759,9 +752,8 @@ class TSCMReportBuilder:
# Check for tracker
tracker_types = ['airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker']
if any(i.get('type') in tracker_types for i in indicators):
if risk_level == 'high_interest':
return 'PB-001 (Tracker Detection)'
if any(i.get('type') in tracker_types for i in indicators) and risk_level == 'high_interest':
return 'PB-001 (Tracker Detection)'
if risk_level == 'high_interest':
return 'PB-002 (Suspicious Device)'
@@ -770,7 +762,7 @@ class TSCMReportBuilder:
return ''
def add_meeting_summary(self, summary: dict) -> 'TSCMReportBuilder':
def add_meeting_summary(self, summary: dict) -> TSCMReportBuilder:
"""Add meeting window summary."""
meeting = ReportMeetingSummary(
name=summary.get('name'),
@@ -792,7 +784,7 @@ class TSCMReportBuilder:
rf: int = 0,
new: int = 0,
missing: int = 0
) -> 'TSCMReportBuilder':
) -> TSCMReportBuilder:
self.report.wifi_devices = wifi
self.report.wifi_clients = wifi_clients
self.report.bluetooth_devices = bluetooth
@@ -802,19 +794,19 @@ class TSCMReportBuilder:
self.report.missing_devices = missing
return self
def add_device_timelines(self, timelines: list[dict]) -> 'TSCMReportBuilder':
def add_device_timelines(self, timelines: list[dict]) -> TSCMReportBuilder:
self.report.device_timelines = timelines
return self
def add_all_indicators(self, indicators: list[dict]) -> 'TSCMReportBuilder':
def add_all_indicators(self, indicators: list[dict]) -> TSCMReportBuilder:
self.report.all_indicators = indicators
return self
def add_baseline_diff(self, diff: dict) -> 'TSCMReportBuilder':
def add_baseline_diff(self, diff: dict) -> TSCMReportBuilder:
self.report.baseline_diff = diff
return self
def add_correlations(self, correlations: list[dict]) -> 'TSCMReportBuilder':
def add_correlations(self, correlations: list[dict]) -> TSCMReportBuilder:
self.report.correlation_data = correlations
return self
@@ -852,9 +844,9 @@ def generate_report(
device_profiles: list[dict],
capabilities: dict,
timelines: list[dict],
baseline_diff: Optional[dict] = None,
meeting_summaries: Optional[list[dict]] = None,
correlations: Optional[list[dict]] = None,
baseline_diff: dict | None = None,
meeting_summaries: list[dict] | None = None,
correlations: list[dict] | None = None,
) -> TSCMReport:
"""
Generate a complete TSCM report from sweep data.
@@ -883,9 +875,8 @@ def generate_report(
if started_at:
if isinstance(started_at, str):
started_at = datetime.fromisoformat(started_at.replace('Z', '+00:00')).replace(tzinfo=None)
if completed_at:
if isinstance(completed_at, str):
completed_at = datetime.fromisoformat(completed_at.replace('Z', '+00:00')).replace(tzinfo=None)
if completed_at and isinstance(completed_at, str):
completed_at = datetime.fromisoformat(completed_at.replace('Z', '+00:00')).replace(tzinfo=None)
builder.set_sweep_times(started_at, completed_at)
# Capabilities
+2 -4
View File
@@ -11,8 +11,6 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional
# =============================================================================
# Signal Strength Classification
@@ -208,8 +206,8 @@ class ConfidenceLevel(Enum):
@dataclass
class SignalAssessment:
"""Complete signal assessment with confidence-safe language."""
rssi: Optional[float]
duration_seconds: Optional[float]
rssi: float | None
duration_seconds: float | None
observation_count: int
signal_strength: SignalStrength
+2 -3
View File
@@ -4,6 +4,7 @@ GitHub update checking and git-based update mechanism.
from __future__ import annotations
import contextlib
import json
import logging
import os
@@ -264,10 +265,8 @@ def get_update_status() -> dict[str, Any]:
last_check_time = None
if last_check:
try:
with contextlib.suppress(ValueError, TypeError):
last_check_time = datetime.fromtimestamp(float(last_check)).isoformat()
except (ValueError, TypeError):
pass
return {
'success': True,
+5 -19
View File
@@ -15,7 +15,7 @@ rtl_fm capture for manual decoding when SatDump is unavailable.
from __future__ import annotations
import io
import contextlib
import os
import pty
import re
@@ -24,8 +24,8 @@ import shutil
import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
@@ -222,10 +222,8 @@ class WeatherSatDecoder:
"""Close the PTY master fd in a thread-safe manner."""
with self._pty_lock:
if self._pty_master_fd is not None:
try:
with contextlib.suppress(OSError):
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
@@ -836,19 +834,7 @@ class WeatherSatDecoder:
capture_phase=self._capture_phase,
))
last_emit_time = now
elif log_type == 'error':
self._emit_progress(CaptureProgress(
status='capturing',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=line,
elapsed_seconds=elapsed,
log_type=log_type,
capture_phase=self._capture_phase,
))
last_emit_time = now
elif log_type == 'signal':
elif log_type == 'error' or log_type == 'signal':
self._emit_progress(CaptureProgress(
status='capturing',
satellite=self._current_satellite,
+3 -3
View File
@@ -8,20 +8,20 @@ from __future__ import annotations
import threading
import uuid
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
from utils.logging import get_logger
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
from utils.weather_sat import CaptureProgress, get_weather_sat_decoder
logger = get_logger('intercept.weather_sat_scheduler')
# Import config defaults
try:
from config import (
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
WEATHER_SAT_SAMPLE_RATE,
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
)
except ImportError:
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30
+51 -55
View File
@@ -8,25 +8,18 @@ Provides unified WiFi scanning with dual-mode architecture:
Also includes channel analysis, hidden SSID correlation, and network aggregation.
"""
from .models import (
WiFiObservation,
WiFiAccessPoint,
WiFiClient,
WiFiProbeRequest,
WiFiScanResult,
WiFiScanStatus,
WiFiCapabilities,
ChannelStats,
ChannelRecommendation,
from .channel_analyzer import (
ChannelAnalyzer,
analyze_channels,
)
from .scanner import (
UnifiedWiFiScanner,
get_wifi_scanner,
reset_wifi_scanner,
)
from .constants import (
AUTH_EAP,
# Auth
AUTH_OPEN,
AUTH_OWE,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
# Bands
BAND_2_4_GHZ,
BAND_5_GHZ,
@@ -36,64 +29,67 @@ from .constants import (
CHANNELS_2_4_GHZ,
CHANNELS_5_GHZ,
CHANNELS_6_GHZ,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
# Security
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_ENTERPRISE,
SECURITY_UNKNOWN,
# Cipher
CIPHER_NONE,
CIPHER_WEP,
CIPHER_TKIP,
CIPHER_CCMP,
CIPHER_GCMP,
# Cipher
CIPHER_NONE,
CIPHER_TKIP,
CIPHER_UNKNOWN,
# Auth
AUTH_OPEN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OWE,
AUTH_UNKNOWN,
# Signal bands
SIGNAL_STRONG,
SIGNAL_MEDIUM,
SIGNAL_WEAK,
SIGNAL_VERY_WEAK,
SIGNAL_UNKNOWN,
CIPHER_WEP,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
PROXIMITY_FAR,
# Proximity bands (consistent with Bluetooth)
PROXIMITY_IMMEDIATE,
PROXIMITY_NEAR,
PROXIMITY_FAR,
PROXIMITY_UNKNOWN,
SCAN_MODE_DEEP,
# Scan modes
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
SECURITY_ENTERPRISE,
# Security
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SIGNAL_MEDIUM,
# Signal bands
SIGNAL_STRONG,
SIGNAL_UNKNOWN,
SIGNAL_VERY_WEAK,
SIGNAL_WEAK,
# Helper functions
get_band_from_channel,
get_band_from_frequency,
get_channel_from_frequency,
get_signal_band,
get_proximity_band,
get_signal_band,
get_vendor_from_mac,
)
from .channel_analyzer import (
ChannelAnalyzer,
analyze_channels,
)
from .hidden_ssid import (
HiddenSSIDCorrelator,
get_hidden_correlator,
)
from .models import (
ChannelRecommendation,
ChannelStats,
WiFiAccessPoint,
WiFiCapabilities,
WiFiClient,
WiFiObservation,
WiFiProbeRequest,
WiFiScanResult,
WiFiScanStatus,
)
from .scanner import (
UnifiedWiFiScanner,
get_wifi_scanner,
reset_wifi_scanner,
)
__all__ = [
# Main scanner
+6 -12
View File
@@ -11,26 +11,20 @@ Analyzes channel congestion based on:
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from dataclasses import dataclass
from .constants import (
BAND_2_4_GHZ,
BAND_5_GHZ,
BAND_6_GHZ,
CHANNELS_2_4_GHZ,
CHANNELS_5_GHZ,
CHANNELS_6_GHZ,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
CHANNEL_FREQUENCIES,
CHANNEL_RSSI_INTERFERENCE_FACTOR,
CHANNEL_WEIGHT_AP_COUNT,
CHANNEL_WEIGHT_CLIENT_COUNT,
CHANNEL_RSSI_INTERFERENCE_FACTOR,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
get_band_from_channel,
)
from .models import WiFiAccessPoint, ChannelStats, ChannelRecommendation
from .models import ChannelRecommendation, ChannelStats, WiFiAccessPoint
logger = logging.getLogger(__name__)
@@ -255,7 +249,7 @@ class ChannelAnalyzer:
if ap_count == 0:
reason = "No APs detected - clear channel"
elif ap_count == 1:
reason = f"1 AP on channel"
reason = "1 AP on channel"
else:
reason = f"{ap_count} APs on channel"
+17 -19
View File
@@ -7,18 +7,18 @@ frames, detecting potential deauth flood attacks.
from __future__ import annotations
import contextlib
import logging
import threading
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Optional, Any
from typing import Any, Callable
from utils.constants import (
DEAUTH_DETECTION_WINDOW,
DEAUTH_ALERT_THRESHOLD,
DEAUTH_CRITICAL_THRESHOLD,
DEAUTH_DETECTION_WINDOW,
DEAUTH_SNIFF_TIMEOUT,
)
@@ -63,7 +63,7 @@ class DeauthPacketInfo:
dst_mac: str
bssid: str
reason_code: int
signal_dbm: Optional[int] = None
signal_dbm: int | None = None
@dataclass
@@ -106,20 +106,20 @@ class DeauthAlert:
# Attacker info
attacker_mac: str
attacker_vendor: Optional[str]
attacker_signal_dbm: Optional[int]
attacker_vendor: str | None
attacker_signal_dbm: int | None
is_spoofed_ap: bool
# Target info
target_mac: str
target_vendor: Optional[str]
target_vendor: str | None
target_type: str # 'client', 'broadcast', 'ap'
target_known_from_scan: bool
# Access point info
ap_bssid: str
ap_essid: Optional[str]
ap_channel: Optional[int]
ap_essid: str | None
ap_channel: int | None
# Attack info
frame_type: str
@@ -184,8 +184,8 @@ class DeauthDetector:
self,
interface: str,
event_callback: Callable[[dict], None],
get_networks: Optional[Callable[[], dict[str, Any]]] = None,
get_clients: Optional[Callable[[], dict[str, Any]]] = None,
get_networks: Callable[[], dict[str, Any]] | None = None,
get_clients: Callable[[], dict[str, Any]] | None = None,
):
"""
Initialize the deauth detector.
@@ -202,7 +202,7 @@ class DeauthDetector:
self.get_clients = get_clients
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Track deauth packets by (src, dst, bssid) tuple
@@ -215,7 +215,7 @@ class DeauthDetector:
# Stats
self._packets_captured = 0
self._alerts_generated = 0
self._started_at: Optional[float] = None
self._started_at: float | None = None
@property
def is_running(self) -> bool:
@@ -296,7 +296,7 @@ class DeauthDetector:
def _sniff_loop(self):
"""Main sniffing loop using scapy."""
try:
from scapy.all import sniff, Dot11, Dot11Deauth, Dot11Disas
from scapy.all import Dot11, Dot11Deauth, Dot11Disas, sniff
except ImportError:
logger.error("scapy not installed. Install with: pip install scapy")
self.event_callback({
@@ -388,10 +388,8 @@ class DeauthDetector:
# Extract signal strength from RadioTap if available
signal_dbm = None
if pkt.haslayer(RadioTap):
try:
with contextlib.suppress(AttributeError):
signal_dbm = pkt[RadioTap].dBm_AntSignal
except AttributeError:
pass
# Create packet info
pkt_info = DeauthPacketInfo(
@@ -579,7 +577,7 @@ class DeauthDetector:
try:
networks = self.get_networks()
return {bssid.upper() for bssid in networks.keys()}
return {bssid.upper() for bssid in networks}
except Exception:
return set()
@@ -587,7 +585,7 @@ class DeauthDetector:
"""Check if source MAC matches a known AP (spoofing indicator)."""
return src_mac.upper() in self._get_known_aps()
def _get_vendor(self, mac: str) -> Optional[str]:
def _get_vendor(self, mac: str) -> str | None:
"""Get vendor from MAC OUI."""
try:
from data.oui import get_manufacturer
+9 -9
View File
@@ -17,9 +17,9 @@ from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Callable, Optional
from typing import Callable
from .constants import (
HIDDEN_CORRELATION_WINDOW_SECONDS,
@@ -29,7 +29,7 @@ from .constants import (
logger = logging.getLogger(__name__)
# Global correlator instance
_correlator_instance: Optional['HiddenSSIDCorrelator'] = None
_correlator_instance: HiddenSSIDCorrelator | None = None
_correlator_lock = threading.Lock()
@@ -92,9 +92,9 @@ class HiddenSSIDCorrelator:
self._revealed: dict[str, CorrelationResult] = {} # BSSID -> result
# Callbacks
self._on_ssid_revealed: Optional[Callable[[CorrelationResult], None]] = None
self._on_ssid_revealed: Callable[[CorrelationResult], None] | None = None
def record_probe(self, client_mac: str, probed_ssid: str, timestamp: Optional[datetime] = None):
def record_probe(self, client_mac: str, probed_ssid: str, timestamp: datetime | None = None):
"""
Record a probe request.
@@ -122,7 +122,7 @@ class HiddenSSIDCorrelator:
# Check for correlations with known hidden APs
self._check_correlations()
def record_association(self, client_mac: str, bssid: str, timestamp: Optional[datetime] = None):
def record_association(self, client_mac: str, bssid: str, timestamp: datetime | None = None):
"""
Record a client association with an AP.
@@ -151,7 +151,7 @@ class HiddenSSIDCorrelator:
# Check for correlations
self._check_correlations()
def record_hidden_ap(self, bssid: str, timestamp: Optional[datetime] = None):
def record_hidden_ap(self, bssid: str, timestamp: datetime | None = None):
"""
Record a hidden access point (empty SSID).
@@ -171,7 +171,7 @@ class HiddenSSIDCorrelator:
# Check for correlations
self._check_correlations()
def get_revealed_ssid(self, bssid: str) -> Optional[str]:
def get_revealed_ssid(self, bssid: str) -> str | None:
"""
Get the revealed SSID for a hidden AP, if known.
@@ -185,7 +185,7 @@ class HiddenSSIDCorrelator:
result = self._revealed.get(bssid.upper())
return result.revealed_ssid if result else None
def get_correlation(self, bssid: str) -> Optional[CorrelationResult]:
def get_correlation(self, bssid: str) -> CorrelationResult | None:
"""
Get the full correlation result for a hidden AP.
+51 -54
View File
@@ -6,20 +6,17 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from .constants import (
BAND_UNKNOWN,
SECURITY_UNKNOWN,
CIPHER_UNKNOWN,
AUTH_UNKNOWN,
WIDTH_UNKNOWN,
SIGNAL_UNKNOWN,
BAND_UNKNOWN,
CIPHER_UNKNOWN,
PROXIMITY_UNKNOWN,
SCAN_MODE_QUICK,
SECURITY_UNKNOWN,
SIGNAL_UNKNOWN,
WIDTH_UNKNOWN,
get_band_from_channel,
get_signal_band,
get_proximity_band,
get_vendor_from_mac,
)
@@ -30,10 +27,10 @@ class WiFiObservation:
timestamp: datetime
bssid: str
essid: Optional[str] = None
channel: Optional[int] = None
frequency_mhz: Optional[int] = None
rssi: Optional[int] = None
essid: str | None = None
channel: int | None = None
frequency_mhz: int | None = None
rssi: int | None = None
# Security
security: str = SECURITY_UNKNOWN
@@ -58,7 +55,7 @@ class WiFiObservation:
return BAND_UNKNOWN
@property
def vendor(self) -> Optional[str]:
def vendor(self) -> str | None:
"""Get vendor name from BSSID."""
return get_vendor_from_mac(self.bssid)
@@ -89,29 +86,29 @@ class WiFiAccessPoint:
# Identity
bssid: str
essid: Optional[str] = None
essid: str | None = None
is_hidden: bool = False
revealed_essid: Optional[str] = None # Revealed through correlation
revealed_essid: str | None = None # Revealed through correlation
# Radio info
channel: Optional[int] = None
frequency_mhz: Optional[int] = None
channel: int | None = None
frequency_mhz: int | None = None
band: str = BAND_UNKNOWN
width: str = WIDTH_UNKNOWN
# Signal aggregation
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: Optional[int] = None
rssi_median: Optional[float] = None
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_variance: Optional[float] = None
rssi_ema: Optional[float] = None
rssi_current: int | None = None
rssi_median: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_variance: float | None = None
rssi_ema: float | None = None
# Proximity/signal bands
signal_band: str = SIGNAL_UNKNOWN
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: Optional[float] = None
estimated_distance_m: float | None = None
distance_confidence: float = 0.0
# Security
@@ -131,7 +128,7 @@ class WiFiAccessPoint:
client_count: int = 0
# Metadata
vendor: Optional[str] = None
vendor: str | None = None
# Heuristic flags
heuristic_flags: list[str] = field(default_factory=list)
@@ -141,7 +138,7 @@ class WiFiAccessPoint:
# Baseline tracking
in_baseline: bool = False
baseline_id: Optional[int] = None
baseline_id: int | None = None
@property
def display_name(self) -> str:
@@ -281,23 +278,23 @@ class WiFiClient:
# Identity
mac: str
vendor: Optional[str] = None
vendor: str | None = None
# Signal
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: Optional[int] = None
rssi_median: Optional[float] = None
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_ema: Optional[float] = None
rssi_current: int | None = None
rssi_median: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_ema: float | None = None
# Proximity
signal_band: str = SIGNAL_UNKNOWN
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: Optional[float] = None
estimated_distance_m: float | None = None
# Association
associated_bssid: Optional[str] = None
associated_bssid: str | None = None
is_associated: bool = False
# Probes
@@ -380,8 +377,8 @@ class WiFiProbeRequest:
timestamp: datetime
client_mac: str
probed_ssid: str
rssi: Optional[int] = None
client_vendor: Optional[str] = None
rssi: int | None = None
client_vendor: str | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
@@ -400,22 +397,22 @@ class ChannelStats:
channel: int
band: str = BAND_UNKNOWN
frequency_mhz: Optional[int] = None
frequency_mhz: int | None = None
# Counts
ap_count: int = 0
client_count: int = 0
# Signal stats
rssi_avg: Optional[float] = None
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_avg: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
# Utilization score (0.0-1.0, lower is better)
utilization_score: float = 0.0
# Recommendation rank (1 = best)
recommendation_rank: Optional[int] = None
recommendation_rank: int | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
@@ -442,7 +439,7 @@ class ChannelRecommendation:
score: float # Lower is better
reason: str
is_dfs: bool = False
recommendation_rank: Optional[int] = None
recommendation_rank: int | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
@@ -471,14 +468,14 @@ class WiFiScanResult:
# Scan metadata
scan_mode: str = SCAN_MODE_QUICK
interface: Optional[str] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
duration_seconds: Optional[float] = None
interface: str | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
duration_seconds: float | None = None
# Status
is_complete: bool = False
error: Optional[str] = None
error: str | None = None
warnings: list[str] = field(default_factory=list)
@property
@@ -545,14 +542,14 @@ class WiFiScanStatus:
is_scanning: bool = False
scan_mode: str = SCAN_MODE_QUICK
interface: Optional[str] = None
started_at: Optional[datetime] = None
interface: str | None = None
started_at: datetime | None = None
networks_found: int = 0
clients_found: int = 0
error: Optional[str] = None
error: str | None = None
@property
def elapsed_seconds(self) -> Optional[float]:
def elapsed_seconds(self) -> float | None:
"""Seconds since scan started."""
if self.started_at:
return (datetime.now() - self.started_at).total_seconds()
@@ -582,20 +579,20 @@ class WiFiCapabilities:
# Interfaces
interfaces: list[dict] = field(default_factory=list)
default_interface: Optional[str] = None
default_interface: str | None = None
# Quick scan tools
has_nmcli: bool = False
has_iw: bool = False
has_iwlist: bool = False
has_airport: bool = False
preferred_quick_tool: Optional[str] = None
preferred_quick_tool: str | None = None
# Deep scan tools
has_airmon_ng: bool = False
has_airodump_ng: bool = False
has_monitor_capable_interface: bool = False
monitor_interface: Optional[str] = None
monitor_interface: str | None = None
# Issues
issues: list[str] = field(default_factory=list)
+2 -2
View File
@@ -4,11 +4,11 @@ WiFi scan output parsers.
Each parser converts tool-specific output into WiFiObservation objects.
"""
from .airodump import parse_airodump_csv
from .airport import parse_airport_scan
from .nmcli import parse_nmcli_scan
from .iw import parse_iw_scan
from .iwlist import parse_iwlist_scan
from .airodump import parse_airodump_csv
from .nmcli import parse_nmcli_scan
__all__ = [
'parse_airport_scan',
+14 -15
View File
@@ -22,29 +22,28 @@ import io
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_OWE,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_WEP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OWE,
AUTH_OPEN,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -63,7 +62,7 @@ def parse_airodump_csv(filepath: str) -> tuple[list[WiFiObservation], list[dict]
clients = []
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
with open(filepath, encoding='utf-8', errors='replace') as f:
content = f.read()
# airodump-ng separates sections with blank lines
+15 -19
View File
@@ -12,33 +12,31 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
CIPHER_CCMP,
CIPHER_NONE,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_WEP,
CIPHER_NONE,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OPEN,
AUTH_UNKNOWN,
WIDTH_20_MHZ,
WIDTH_40_MHZ,
get_band_from_channel,
CHANNEL_FREQUENCIES,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -68,7 +66,7 @@ def parse_airport_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_airport_line(line: str) -> Optional[WiFiObservation]:
def _parse_airport_line(line: str) -> WiFiObservation | None:
"""Parse a single line of airport output."""
# airport output is space-aligned, need careful parsing
# Format: SSID (variable width) BSSID RSSI CHANNEL HT CC SECURITY
@@ -95,10 +93,8 @@ def _parse_airport_line(line: str) -> Optional[WiFiObservation]:
ssid = line[:bssid_pos].strip()
# Handle hidden network indicator
is_hidden = False
if ssid == '--' or not ssid:
ssid = None
is_hidden = True
# Parse remainder after BSSID
remainder = line[bssid_match.end():].strip()
+13 -16
View File
@@ -24,35 +24,32 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_OWE,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CIPHER_CCMP,
CIPHER_GCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_GCMP,
CIPHER_WEP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OWE,
AUTH_OPEN,
AUTH_UNKNOWN,
WIDTH_20_MHZ,
WIDTH_40_MHZ,
WIDTH_80_MHZ,
WIDTH_160_MHZ,
get_channel_from_frequency,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -90,7 +87,7 @@ def parse_iw_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_iw_block(lines: list[str]) -> Optional[WiFiObservation]:
def _parse_iw_block(lines: list[str]) -> WiFiObservation | None:
"""Parse a single BSS block from iw output."""
try:
# First line: BSS 00:11:22:33:44:55(on wlan0) -- associated
+11 -13
View File
@@ -25,27 +25,25 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_PSK,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA_WPA2,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_WEP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_EAP,
AUTH_OPEN,
AUTH_UNKNOWN,
get_channel_from_frequency,
CHANNEL_FREQUENCIES,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -83,7 +81,7 @@ def parse_iwlist_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_iwlist_block(lines: list[str]) -> Optional[WiFiObservation]:
def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None:
"""Parse a single Cell block from iwlist output."""
try:
# Extract BSSID from first line
+17 -19
View File
@@ -11,30 +11,28 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_ENTERPRISE,
SECURITY_UNKNOWN,
AUTH_EAP,
AUTH_OPEN,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OPEN,
AUTH_UNKNOWN,
SECURITY_ENTERPRISE,
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
get_channel_from_frequency,
get_band_from_frequency,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -62,7 +60,7 @@ def parse_nmcli_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_nmcli_line(line: str) -> Optional[WiFiObservation]:
def _parse_nmcli_line(line: str) -> WiFiObservation | None:
"""Parse a single line of nmcli terse output."""
try:
# nmcli terse format uses : as delimiter but escapes colons in values with \:
@@ -188,7 +186,7 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]:
cipher = CIPHER_UNKNOWN
if security in (SECURITY_WPA2, SECURITY_WPA3, SECURITY_WPA2_WPA3, SECURITY_ENTERPRISE):
cipher = CIPHER_CCMP
elif security == SECURITY_WPA or security == SECURITY_WPA_WPA2:
elif security in (SECURITY_WPA, SECURITY_WPA_WPA2):
cipher = CIPHER_TKIP # Often TKIP for mixed mode
# Determine auth
+44 -53
View File
@@ -17,44 +17,43 @@ import shutil
import subprocess
import threading
import time
from collections.abc import Generator
from datetime import datetime
from pathlib import Path
from typing import Callable, Generator, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from .deauth_detector import DeauthDetector
import contextlib
from .constants import (
DEFAULT_QUICK_SCAN_TIMEOUT,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
QUICK_SCAN_TOOLS_LINUX,
QUICK_SCAN_TOOLS_DARWIN,
TOOL_TIMEOUT_QUICK,
TOOL_TIMEOUT_DETECT,
NETWORK_STALE_TIMEOUT,
MAX_RSSI_SAMPLES,
SCAN_MODE_DEEP,
SCAN_MODE_QUICK,
TOOL_TIMEOUT_DETECT,
WIFI_EMA_ALPHA,
get_signal_band,
get_proximity_band,
get_signal_band,
get_vendor_from_mac,
)
from .models import (
ChannelRecommendation,
ChannelStats,
WiFiAccessPoint,
WiFiCapabilities,
WiFiClient,
WiFiObservation,
WiFiProbeRequest,
WiFiScanResult,
WiFiScanStatus,
WiFiCapabilities,
WiFiObservation,
ChannelStats,
ChannelRecommendation,
)
logger = logging.getLogger(__name__)
# Global scanner instance
_scanner_instance: Optional['UnifiedWiFiScanner'] = None
_scanner_instance: UnifiedWiFiScanner | None = None
_scanner_lock = threading.Lock()
@@ -66,7 +65,7 @@ class UnifiedWiFiScanner:
Deep Scan: Continuous monitoring with airodump-ng
"""
def __init__(self, interface: Optional[str] = None):
def __init__(self, interface: str | None = None):
"""
Initialize WiFi scanner.
@@ -78,7 +77,7 @@ class UnifiedWiFiScanner:
# State
self._status = WiFiScanStatus()
self._capabilities: Optional[WiFiCapabilities] = None
self._capabilities: WiFiCapabilities | None = None
# Discovered entities
self._access_points: dict[str, WiFiAccessPoint] = {} # bssid -> AP
@@ -86,24 +85,24 @@ class UnifiedWiFiScanner:
self._probe_requests: list[WiFiProbeRequest] = []
# Deep scan process
self._deep_scan_process: Optional[subprocess.Popen] = None
self._deep_scan_thread: Optional[threading.Thread] = None
self._deep_scan_process: subprocess.Popen | None = None
self._deep_scan_thread: threading.Thread | None = None
self._deep_scan_stop_event = threading.Event()
# Deauth detector
self._deauth_detector: Optional['DeauthDetector'] = None
self._deauth_detector: DeauthDetector | None = None
# Event queue for SSE streaming
self._event_queue: queue.Queue = queue.Queue(maxsize=1000)
# Callbacks
self._on_network_updated: Optional[Callable[[WiFiAccessPoint], None]] = None
self._on_client_updated: Optional[Callable[[WiFiClient], None]] = None
self._on_probe_request: Optional[Callable[[WiFiProbeRequest], None]] = None
self._on_network_updated: Callable[[WiFiAccessPoint], None] | None = None
self._on_client_updated: Callable[[WiFiClient], None] | None = None
self._on_probe_request: Callable[[WiFiProbeRequest], None] | None = None
# Baseline tracking
self._baseline_networks: set[str] = set() # BSSIDs in baseline
self._baseline_set_at: Optional[datetime] = None
self._baseline_set_at: datetime | None = None
# =========================================================================
# Properties
@@ -374,7 +373,7 @@ class UnifiedWiFiScanner:
def quick_scan(
self,
interface: Optional[str] = None,
interface: str | None = None,
timeout: float = DEFAULT_QUICK_SCAN_TIMEOUT,
) -> WiFiScanResult:
"""
@@ -664,10 +663,10 @@ class UnifiedWiFiScanner:
def start_deep_scan(
self,
interface: Optional[str] = None,
interface: str | None = None,
band: str = 'all',
channel: Optional[int] = None,
channels: Optional[list[int]] = None,
channel: int | None = None,
channels: list[int] | None = None,
) -> bool:
"""
Start continuous deep scan with airodump-ng.
@@ -733,8 +732,8 @@ class UnifiedWiFiScanner:
Returns:
True if scan was stopped.
"""
cleanup_process: Optional[subprocess.Popen] = None
cleanup_thread: Optional[threading.Thread] = None
cleanup_process: subprocess.Popen | None = None
cleanup_thread: threading.Thread | None = None
cleanup_detector = None
with self._lock:
@@ -760,8 +759,8 @@ class UnifiedWiFiScanner:
cleanup_start = time.perf_counter()
def _finalize_stop(
process: Optional[subprocess.Popen],
scan_thread: Optional[threading.Thread],
process: subprocess.Popen | None,
scan_thread: threading.Thread | None,
detector,
) -> None:
if detector:
@@ -777,10 +776,8 @@ class UnifiedWiFiScanner:
process.terminate()
process.wait(timeout=1.5)
except Exception:
try:
with contextlib.suppress(Exception):
process.kill()
except Exception:
pass
if scan_thread and scan_thread.is_alive():
scan_thread.join(timeout=1.5)
@@ -801,14 +798,14 @@ class UnifiedWiFiScanner:
self,
interface: str,
band: str,
channel: Optional[int],
channels: Optional[list[int]],
channel: int | None,
channels: list[int] | None,
):
"""Background thread for running airodump-ng."""
from .parsers.airodump import parse_airodump_csv
import tempfile
from .parsers.airodump import parse_airodump_csv
# Create temp directory for output files
with tempfile.TemporaryDirectory(prefix='wifi_scan_') as tmpdir:
output_prefix = os.path.join(tmpdir, 'scan')
@@ -829,7 +826,7 @@ class UnifiedWiFiScanner:
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
process: Optional[subprocess.Popen] = None
process: subprocess.Popen | None = None
try:
process = subprocess.Popen(
cmd,
@@ -848,10 +845,8 @@ class UnifiedWiFiScanner:
process.terminate()
process.wait(timeout=1.0)
except Exception:
try:
with contextlib.suppress(Exception):
process.kill()
except Exception:
pass
return
csv_file = f"{output_prefix}-01.csv"
@@ -1129,8 +1124,6 @@ class UnifiedWiFiScanner:
def _calculate_channel_stats(self) -> list[ChannelStats]:
"""Calculate statistics for each channel."""
from .constants import (
CHANNELS_2_4_GHZ,
CHANNELS_5_GHZ,
CHANNEL_FREQUENCIES,
get_band_from_channel,
)
@@ -1175,10 +1168,10 @@ class UnifiedWiFiScanner:
def _generate_recommendations(self, stats: list[ChannelStats]) -> list[ChannelRecommendation]:
"""Generate channel recommendations."""
from .constants import (
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
BAND_2_4_GHZ,
BAND_5_GHZ,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
)
recommendations = []
@@ -1273,12 +1266,12 @@ class UnifiedWiFiScanner:
# Data Access
# =========================================================================
def get_network(self, bssid: str) -> Optional[WiFiAccessPoint]:
def get_network(self, bssid: str) -> WiFiAccessPoint | None:
"""Get a specific network by BSSID."""
with self._lock:
return self._access_points.get(bssid.upper())
def get_client(self, mac: str) -> Optional[WiFiClient]:
def get_client(self, mac: str) -> WiFiClient | None:
"""Get a specific client by MAC."""
with self._lock:
return self._clients.get(mac.upper())
@@ -1336,10 +1329,8 @@ class UnifiedWiFiScanner:
alert_id = event.get('id', str(time.time()))
app_module.deauth_alerts[alert_id] = event
if hasattr(app_module, 'deauth_detector_queue'):
try:
with contextlib.suppress(queue.Full):
app_module.deauth_detector_queue.put_nowait(event)
except queue.Full:
pass
except Exception as e:
logger.debug(f"Error storing deauth alert: {e}")
@@ -1389,7 +1380,7 @@ class UnifiedWiFiScanner:
self._deauth_detector = None
@property
def deauth_detector(self) -> Optional['DeauthDetector']:
def deauth_detector(self) -> DeauthDetector | None:
"""Get the deauth detector instance."""
return self._deauth_detector
@@ -1416,7 +1407,7 @@ class UnifiedWiFiScanner:
# Module-level functions
# =============================================================================
def get_wifi_scanner(interface: Optional[str] = None) -> UnifiedWiFiScanner:
def get_wifi_scanner(interface: str | None = None) -> UnifiedWiFiScanner:
"""
Get or create the global WiFi scanner instance.