mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
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:
+29
-29
@@ -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,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
|
||||
|
||||
@@ -5,7 +5,6 @@ HTTP client for communicating with remote Intercept agents.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user