chore: Bump version to v2.18.0

Bluetooth enhancements (service data inspector, appearance codes, MAC
cluster tracking, behavioral flags, IRK badges, distance estimation),
ACARS SoapySDR multi-backend support, dump1090 stale process cleanup,
GPS error state, and proximity radar/signal card UI improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-16 15:12:10 +00:00
parent 2a73318457
commit 99d52eafe7
28 changed files with 1212 additions and 169 deletions
+6
View File
@@ -606,6 +606,12 @@ class DeviceAggregator:
return result
def get_fingerprint_mac_count(self, fingerprint_id: str) -> int:
"""Return how many distinct device_ids share a fingerprint."""
with self._lock:
device_ids = self._fingerprint_to_devices.get(fingerprint_id)
return len(device_ids) if device_ids else 0
def prune_ring_buffer(self) -> int:
"""Prune old observations from ring buffer."""
return self._ring_buffer.prune_old()
+57
View File
@@ -101,6 +101,7 @@ ADDRESS_TYPE_RANDOM = 'random'
ADDRESS_TYPE_RANDOM_STATIC = 'random_static'
ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address
ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address
ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available)
# =============================================================================
# PROTOCOL TYPES
@@ -278,3 +279,59 @@ MINOR_WEARABLE = {
0x04: 'Helmet',
0x05: 'Glasses',
}
# =============================================================================
# BLE APPEARANCE CODES (GAP Appearance values)
# =============================================================================
BLE_APPEARANCE_NAMES: dict[int, str] = {
0: 'Unknown',
64: 'Phone',
128: 'Computer',
192: 'Watch',
193: 'Sports Watch',
256: 'Clock',
320: 'Display',
384: 'Remote Control',
448: 'Eye Glasses',
512: 'Tag',
576: 'Keyring',
640: 'Media Player',
704: 'Barcode Scanner',
768: 'Thermometer',
832: 'Heart Rate Sensor',
896: 'Blood Pressure',
960: 'HID',
961: 'Keyboard',
962: 'Mouse',
963: 'Joystick',
964: 'Gamepad',
965: 'Digitizer Tablet',
966: 'Card Reader',
967: 'Digital Pen',
968: 'Barcode Scanner (HID)',
1024: 'Glucose Monitor',
1088: 'Running Speed Sensor',
1152: 'Cycling',
1216: 'Control Device',
1280: 'Network Device',
1344: 'Sensor',
1408: 'Light Fixture',
1472: 'Fan',
1536: 'HVAC',
1600: 'Access Control',
1664: 'Motorized Device',
1728: 'Power Device',
1792: 'Light Source',
3136: 'Pulse Oximeter',
3200: 'Weight Scale',
3264: 'Personal Mobility',
5184: 'Outdoor Sports Activity',
}
def get_appearance_name(code: int | None) -> str | None:
"""Look up a human-readable name for a BLE appearance code."""
if code is None:
return None
return BLE_APPEARANCE_NAMES.get(code)
+7 -2
View File
@@ -12,6 +12,7 @@ from typing import Optional
from .constants import (
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_UUID,
)
@@ -46,10 +47,14 @@ def generate_device_key(
if identity_address:
return f"id:{identity_address.upper()}"
# Priority 2: Use public or random_static addresses directly
# Priority 2: Use public or random_static addresses directly (not platform UUIDs)
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
return f"mac:{address.upper()}"
# Priority 2b: CoreBluetooth UUIDs are stable per-system, use as identifier
if address_type == ADDRESS_TYPE_UUID:
return f"uuid:{address.upper()}"
# Priority 3: Generate fingerprint hash for random addresses
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
@@ -102,7 +107,7 @@ def is_randomized_mac(address_type: str) -> bool:
Returns:
True if the address is randomized, False otherwise.
"""
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC)
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_UUID)
def extract_key_type(device_key: str) -> str:
+8 -1
View File
@@ -24,8 +24,12 @@ from .constants import (
BLUETOOTHCTL_TIMEOUT,
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_UUID,
MANUFACTURER_NAMES,
)
# 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}$')
from .models import BTObservation
logger = logging.getLogger(__name__)
@@ -132,7 +136,10 @@ class BleakScanner:
"""Convert bleak device to BTObservation."""
# Determine address type from address format
address_type = ADDRESS_TYPE_PUBLIC
if device.address and ':' in device.address:
if device.address and _CB_UUID_RE.match(device.address):
# macOS CoreBluetooth returns a platform UUID instead of a real MAC
address_type = ADDRESS_TYPE_UUID
elif device.address and ':' in device.address:
# Check if first byte indicates random address
first_byte = int(device.address.split(':')[0], 16)
if (first_byte & 0xC0) == 0xC0: # Random static
+45 -13
View File
@@ -18,6 +18,7 @@ from .constants import (
RANGE_UNKNOWN,
PROTOCOL_BLE,
PROXIMITY_UNKNOWN,
get_appearance_name,
)
# Import tracker types (will be available after tracker_signatures module loads)
@@ -148,10 +149,10 @@ class BTDeviceAggregate:
is_strong_stable: bool = False
has_random_address: bool = False
# Baseline tracking
in_baseline: bool = False
baseline_id: Optional[int] = None
seen_before: bool = False
# Baseline tracking
in_baseline: bool = False
baseline_id: Optional[int] = None
seen_before: bool = False
# Tracker detection fields
is_tracker: bool = False
@@ -165,6 +166,10 @@ class BTDeviceAggregate:
risk_score: float = 0.0 # 0.0 to 1.0
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
# Payload fingerprint (survives MAC randomization)
payload_fingerprint_id: Optional[str] = None
payload_fingerprint_stability: float = 0.0
@@ -275,10 +280,10 @@ class BTDeviceAggregate:
},
'heuristic_flags': self.heuristic_flags,
# Baseline
'in_baseline': self.in_baseline,
'baseline_id': self.baseline_id,
'seen_before': self.seen_before,
# Baseline
'in_baseline': self.in_baseline,
'baseline_id': self.baseline_id,
'seen_before': self.seen_before,
# Tracker detection
'tracker': {
@@ -296,6 +301,11 @@ class BTDeviceAggregate:
'risk_factors': self.risk_factors,
},
# IRK
'has_irk': self.irk_hex is not None,
'irk_hex': self.irk_hex,
'irk_source_name': self.irk_source_name,
# Fingerprint
'fingerprint': {
'id': self.payload_fingerprint_id,
@@ -319,24 +329,46 @@ class BTDeviceAggregate:
'rssi_current': self.rssi_current,
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
'rssi_min': self.rssi_min,
'rssi_max': self.rssi_max,
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
'range_band': self.range_band,
'proximity_band': self.proximity_band,
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
'distance_confidence': round(self.distance_confidence, 2),
'is_randomized_mac': self.is_randomized_mac,
'last_seen': self.last_seen.isoformat(),
'first_seen': self.first_seen.isoformat(),
'age_seconds': self.age_seconds,
'duration_seconds': self.duration_seconds,
'seen_count': self.seen_count,
'heuristic_flags': self.heuristic_flags,
'in_baseline': self.in_baseline,
'seen_before': self.seen_before,
# Tracker info for list view
'is_tracker': self.is_tracker,
'seen_rate': round(self.seen_rate, 2),
'tx_power': self.tx_power,
'manufacturer_id': self.manufacturer_id,
'appearance': self.appearance,
'appearance_name': get_appearance_name(self.appearance),
'is_connectable': self.is_connectable,
'service_uuids': self.service_uuids,
'service_data': {k: v.hex() for k, v in self.service_data.items()},
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
'heuristic_flags': self.heuristic_flags,
'is_persistent': self.is_persistent,
'is_beacon_like': self.is_beacon_like,
'is_strong_stable': self.is_strong_stable,
'in_baseline': self.in_baseline,
'seen_before': self.seen_before,
# Tracker info for list view
'is_tracker': self.is_tracker,
'tracker_type': self.tracker_type,
'tracker_name': self.tracker_name,
'tracker_confidence': self.tracker_confidence,
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
'tracker_evidence': self.tracker_evidence,
'risk_score': round(self.risk_score, 2),
'risk_factors': self.risk_factors,
'has_irk': self.irk_hex is not None,
'irk_hex': self.irk_hex,
'irk_source_name': self.irk_source_name,
'fingerprint_id': self.payload_fingerprint_id,
}
+76 -1
View File
@@ -24,7 +24,9 @@ from .constants import (
)
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
logger = logging.getLogger(__name__)
@@ -57,6 +59,7 @@ class BluetoothScanner:
# 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
# Event queue for SSE streaming
@@ -113,6 +116,8 @@ class BluetoothScanner:
if mode == 'dbus':
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
elif mode == 'ubertooth':
started, backend_used = self._start_ubertooth()
# Fallback: try non-DBus methods if DBus failed or wasn't requested
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
@@ -168,6 +173,18 @@ class BluetoothScanner:
logger.warning(f"DBus scanner failed: {e}")
return False, None
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
"""Start Ubertooth One scanner."""
try:
self._ubertooth_scanner = UbertoothScanner(
on_observation=self._handle_observation,
)
if self._ubertooth_scanner.start():
return True, 'ubertooth'
except Exception as e:
logger.warning(f"Ubertooth scanner failed: {e}")
return False, None
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
"""Start fallback scanner."""
try:
@@ -204,6 +221,10 @@ class BluetoothScanner:
self._fallback_scanner.stop()
self._fallback_scanner = None
if self._ubertooth_scanner:
self._ubertooth_scanner.stop()
self._ubertooth_scanner = None
# Update status
self._status.is_scanning = False
self._active_backend = None
@@ -216,6 +237,47 @@ class BluetoothScanner:
logger.info("Bluetooth scan stopped")
def _match_irk(self, device: BTDeviceAggregate) -> None:
"""Check if a device address resolves against any paired IRK."""
if device.irk_hex is not None:
return # Already matched
address = device.address
if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32):
return
# Only attempt RPA resolution on 6-byte addresses
addr_clean = address.replace(':', '').replace('-', '')
if len(addr_clean) != 12:
return
try:
paired = get_paired_irks()
except Exception:
return
if not paired:
return
try:
from utils.bt_locate import resolve_rpa
except ImportError:
return
for entry in paired:
irk_hex = entry.get('irk_hex', '')
if not irk_hex or len(irk_hex) != 32:
continue
try:
irk = bytes.fromhex(irk_hex)
if resolve_rpa(irk, address):
device.irk_hex = irk_hex
device.irk_source_name = entry.get('name')
logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}")
return
except Exception:
continue
def _handle_observation(self, observation: BTObservation) -> None:
"""Handle incoming observation from scanner backend."""
try:
@@ -225,15 +287,27 @@ class BluetoothScanner:
# Evaluate heuristics
self._heuristics.evaluate(device)
# Check for IRK match
self._match_irk(device)
# Update device count
with self._lock:
self._status.devices_found = self._aggregator.device_count
# Build summary with MAC cluster count
summary = device.to_summary_dict()
if device.payload_fingerprint_id:
summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count(
device.payload_fingerprint_id
)
else:
summary['mac_cluster_count'] = 0
# Queue event
self._queue_event({
'type': 'device',
'action': 'update',
'device': device.to_summary_dict(),
'device': summary,
})
# Callbacks
@@ -398,6 +472,7 @@ class BluetoothScanner:
backend_alive = (
(self._dbus_scanner and self._dbus_scanner.is_scanning)
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
or (self._ubertooth_scanner and self._ubertooth_scanner.is_scanning)
)
if not backend_alive:
self._status.is_scanning = False
+178
View File
@@ -483,3 +483,181 @@ def get_current_position() -> GPSPosition | None:
if client:
return client.position
return None
# ============================================
# GPS device detection and gpsd auto-start
# ============================================
_gpsd_process: 'subprocess.Popen | None' = None
_gpsd_process_lock = threading.Lock()
def detect_gps_devices() -> list[dict]:
"""
Detect connected GPS serial devices.
Returns list of dicts with 'path' and 'description' keys.
"""
import glob
import os
import platform
devices: list[dict] = []
system = platform.system()
if system == 'Linux':
# Common USB GPS device paths
patterns = ['/dev/ttyUSB*', '/dev/ttyACM*']
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
desc = _describe_device_linux(path)
devices.append({'path': path, 'description': desc})
# Also check /dev/serial/by-id for descriptive names
serial_dir = '/dev/serial/by-id'
if os.path.isdir(serial_dir):
for name in sorted(os.listdir(serial_dir)):
full = os.path.join(serial_dir, name)
real = os.path.realpath(full)
# Skip if we already found this device
if any(d['path'] == real for d in devices):
# Update description with the more descriptive name
for d in devices:
if d['path'] == real:
d['description'] = name
continue
devices.append({'path': real, 'description': name})
elif system == 'Darwin':
# macOS: USB serial devices (prefer cu. over tty. for outgoing)
patterns = ['/dev/cu.usbmodem*', '/dev/cu.usbserial*']
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
desc = _describe_device_macos(path)
devices.append({'path': path, 'description': desc})
# Sort: devices with GPS-related descriptions first
gps_keywords = ('gps', 'gnss', 'u-blox', 'ublox', 'nmea', 'sirf', 'navigation')
devices.sort(key=lambda d: (
0 if any(k in d['description'].lower() for k in gps_keywords) else 1
))
return devices
def _describe_device_linux(path: str) -> str:
"""Get a human-readable description of a Linux serial device."""
import os
basename = os.path.basename(path)
# Try to read from sysfs
try:
# /sys/class/tty/ttyUSB0/device/../product
sysfs = f'/sys/class/tty/{basename}/device/../product'
if os.path.exists(sysfs):
with open(sysfs) as f:
return f.read().strip()
except Exception:
pass
return basename
def _describe_device_macos(path: str) -> str:
"""Get a description of a macOS serial device."""
import os
return os.path.basename(path)
def is_gpsd_running(host: str = 'localhost', port: int = 2947) -> bool:
"""Check if gpsd is reachable."""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1.0)
sock.connect((host, port))
sock.close()
return True
except Exception:
return False
def start_gpsd_daemon(device_path: str, host: str = 'localhost',
port: int = 2947) -> tuple[bool, str]:
"""
Start gpsd daemon pointing at the given device.
Returns (success, message) tuple.
"""
import shutil
import subprocess
global _gpsd_process
with _gpsd_process_lock:
# Already running?
if is_gpsd_running(host, port):
return True, 'gpsd already running'
gpsd_bin = shutil.which('gpsd')
if not gpsd_bin:
return False, 'gpsd not installed'
# Stop any existing managed process
stop_gpsd_daemon()
try:
import os
if not os.path.exists(device_path):
return False, f'Device {device_path} not found'
cmd = [gpsd_bin, '-N', '-n', '-S', str(port), device_path]
logger.info(f"Starting gpsd: {' '.join(cmd)}")
print(f"[GPS] Starting gpsd: {' '.join(cmd)}", flush=True)
_gpsd_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
# Give gpsd a moment to start
import time
time.sleep(1.5)
if _gpsd_process.poll() is not None:
stderr = ''
if _gpsd_process.stderr:
stderr = _gpsd_process.stderr.read().decode('utf-8', errors='ignore').strip()
msg = f'gpsd exited with code {_gpsd_process.returncode}'
if stderr:
msg += f': {stderr}'
return False, msg
# Verify it's listening
if is_gpsd_running(host, port):
return True, f'gpsd started on {device_path}'
else:
return False, 'gpsd started but not accepting connections'
except Exception as e:
logger.error(f"Failed to start gpsd: {e}")
return False, str(e)
def stop_gpsd_daemon() -> None:
"""Stop the managed gpsd daemon process."""
global _gpsd_process
with _gpsd_process_lock:
if _gpsd_process and _gpsd_process.poll() is None:
try:
_gpsd_process.terminate()
_gpsd_process.wait(timeout=3.0)
except Exception:
try:
_gpsd_process.kill()
except Exception:
pass
logger.info("Stopped gpsd daemon")
print("[GPS] Stopped gpsd daemon", flush=True)
_gpsd_process = None
+90
View File
@@ -2,11 +2,14 @@ from __future__ import annotations
import atexit
import logging
import os
import platform
import signal
import subprocess
import re
import threading
import time
from pathlib import Path
from typing import Any, Callable
from .dependencies import check_tool
@@ -117,6 +120,93 @@ def cleanup_stale_processes() -> None:
pass
_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid'
def write_dump1090_pid(pid: int) -> None:
"""Write the PID of an app-spawned dump1090 process to a PID file."""
try:
_DUMP1090_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
_DUMP1090_PID_FILE.write_text(str(pid))
logger.debug(f"Wrote dump1090 PID file: {pid}")
except OSError as e:
logger.warning(f"Failed to write dump1090 PID file: {e}")
def clear_dump1090_pid() -> None:
"""Remove the dump1090 PID file."""
try:
_DUMP1090_PID_FILE.unlink(missing_ok=True)
logger.debug("Cleared dump1090 PID file")
except OSError as e:
logger.warning(f"Failed to clear dump1090 PID file: {e}")
def _is_dump1090_process(pid: int) -> bool:
"""Check if the given PID is actually a dump1090/readsb process."""
try:
if platform.system() == 'Linux':
cmdline_path = Path(f'/proc/{pid}/cmdline')
if cmdline_path.exists():
cmdline = cmdline_path.read_bytes().replace(b'\x00', b' ').decode('utf-8', errors='ignore')
return 'dump1090' in cmdline or 'readsb' in cmdline
# macOS or fallback
result = subprocess.run(
['ps', '-p', str(pid), '-o', 'comm='],
capture_output=True, text=True, timeout=5
)
comm = result.stdout.strip()
return 'dump1090' in comm or 'readsb' in comm
except Exception:
return False
def cleanup_stale_dump1090() -> None:
"""Kill a stale app-spawned dump1090 using the PID file.
Safe no-op if no PID file exists, process is dead, or PID was reused
by another program.
"""
if not _DUMP1090_PID_FILE.exists():
return
try:
pid = int(_DUMP1090_PID_FILE.read_text().strip())
except (ValueError, OSError) as e:
logger.warning(f"Invalid dump1090 PID file: {e}")
clear_dump1090_pid()
return
# Verify this PID is still a dump1090/readsb process
if not _is_dump1090_process(pid):
logger.debug(f"PID {pid} is not dump1090/readsb (dead or reused), removing stale PID file")
clear_dump1090_pid()
return
# Kill the process group
logger.info(f"Killing stale app-spawned dump1090 (PID {pid})")
try:
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
# Brief wait for graceful shutdown
for _ in range(10):
try:
os.kill(pid, 0) # Check if still alive
time.sleep(0.2)
except OSError:
break
else:
# Still alive, force kill
try:
os.killpg(pgid, signal.SIGKILL)
except OSError:
pass
except OSError as e:
logger.debug(f"Error killing stale dump1090 PID {pid}: {e}")
clear_dump1090_pid()
def is_valid_mac(mac: str | None) -> bool:
"""Validate MAC address format."""
if not mac: