mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -41,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bluez \
|
||||
bluetooth \
|
||||
# GPS support
|
||||
gpsd \
|
||||
gpsd-clients \
|
||||
# Utilities
|
||||
# APRS
|
||||
|
||||
57
app.py
57
app.py
@@ -29,7 +29,7 @@ from flask import Flask, render_template, jsonify, send_file, Response, request,
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes
|
||||
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
||||
from utils.sdr import SDRFactory
|
||||
from utils.cleanup import DataStore, cleanup_manager
|
||||
from utils.constants import (
|
||||
@@ -647,27 +647,27 @@ def export_bluetooth() -> Response:
|
||||
})
|
||||
|
||||
|
||||
def _get_subghz_active() -> bool:
|
||||
"""Check if SubGHz manager has an active process."""
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
return get_subghz_manager().active_mode != 'idle'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_dmr_active() -> bool:
|
||||
"""Check if Digital Voice decoder has an active process."""
|
||||
try:
|
||||
from routes import dmr as dmr_module
|
||||
proc = dmr_module.dmr_dsd_process
|
||||
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
def _get_subghz_active() -> bool:
|
||||
"""Check if SubGHz manager has an active process."""
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
return get_subghz_manager().active_mode != 'idle'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_dmr_active() -> bool:
|
||||
"""Check if Digital Voice decoder has an active process."""
|
||||
try:
|
||||
from routes import dmr as dmr_module
|
||||
proc = dmr_module.dmr_dsd_process
|
||||
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import time
|
||||
return jsonify({
|
||||
@@ -681,12 +681,12 @@ def health_check() -> Response:
|
||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'dmr': _get_dmr_active(),
|
||||
'subghz': _get_subghz_active(),
|
||||
},
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'dmr': _get_dmr_active(),
|
||||
'subghz': _get_subghz_active(),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'vessel_count': len(ais_vessels),
|
||||
@@ -877,6 +877,7 @@ def main() -> None:
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
cleanup_stale_dump1090()
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
|
||||
14
config.py
14
config.py
@@ -7,10 +7,22 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.17.0"
|
||||
VERSION = "2.18.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.18.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
|
||||
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
|
||||
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
|
||||
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
||||
"GPS: error state indicator and UI refinements",
|
||||
"Proximity radar and signal card UI improvements",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.17.0",
|
||||
"date": "February 2026",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.17.0"
|
||||
version = "2.18.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -20,8 +20,9 @@ from flask import Blueprint, jsonify, request, Response
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
@@ -250,12 +251,22 @@ def start_acars() -> Response:
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build acarsdec command
|
||||
# Different forks have different syntax:
|
||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||
# Note: gain/ppm must come BEFORE -r
|
||||
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
|
||||
# Note: gain/ppm must come BEFORE -r/-d
|
||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||
cmd = [acarsdec_path]
|
||||
if json_flag == '--output':
|
||||
@@ -266,21 +277,33 @@ def start_acars() -> Response:
|
||||
else:
|
||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||
|
||||
# Add gain if not auto (must be before -r)
|
||||
# Add gain if not auto (must be before -r/-d)
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if specified (must be before -r)
|
||||
# Add PPM correction if specified (must be before -r/-d)
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Add device and frequencies
|
||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
||||
if json_flag == '--output':
|
||||
if is_soapy:
|
||||
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
device_str = builder._build_device_string(sdr_device)
|
||||
if json_flag == '--output':
|
||||
cmd.extend(['-m', '256'])
|
||||
cmd.extend(['--soapysdr', device_str])
|
||||
else:
|
||||
cmd.extend(['-d', device_str])
|
||||
elif json_flag == '--output':
|
||||
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
|
||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||
cmd.extend(['-m', '256'])
|
||||
cmd.extend(['--rtlsdr', str(device)])
|
||||
else:
|
||||
# TLeconte fork RTL-SDR: -r <device>
|
||||
cmd.extend(['-r', str(device)])
|
||||
cmd.extend(frequencies)
|
||||
|
||||
@@ -392,13 +415,13 @@ def stream_acars() -> Response:
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
|
||||
@@ -38,6 +38,7 @@ from config import (
|
||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
|
||||
from utils.validation import (
|
||||
validate_device_index, validate_gain,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
@@ -633,6 +634,9 @@ def start_adsb():
|
||||
'session': session
|
||||
})
|
||||
|
||||
# Kill any stale app-spawned dump1090 from a previous run before checking the port
|
||||
cleanup_stale_dump1090()
|
||||
|
||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||
existing_service = check_dump1090_service()
|
||||
if existing_service:
|
||||
@@ -685,6 +689,7 @@ def start_adsb():
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
clear_dump1090_pid()
|
||||
logger.info("Killed stale ADS-B process")
|
||||
|
||||
# Check if device is available before starting local dump1090
|
||||
@@ -721,6 +726,7 @@ def start_adsb():
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
write_dump1090_pid(app_module.adsb_process.pid)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
|
||||
@@ -819,6 +825,7 @@ def stop_adsb():
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
clear_dump1090_pid()
|
||||
logger.info("ADS-B process stopped")
|
||||
|
||||
# Release device from registry
|
||||
|
||||
@@ -229,7 +229,7 @@ def start_scan():
|
||||
rssi_threshold = data.get('rssi_threshold', -100)
|
||||
|
||||
# Validate mode
|
||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||
if mode not in valid_modes:
|
||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@ from flask import Blueprint, Response, jsonify
|
||||
from utils.gps import (
|
||||
GPSPosition,
|
||||
GPSSkyData,
|
||||
detect_gps_devices,
|
||||
get_current_position,
|
||||
get_gps_reader,
|
||||
is_gpsd_running,
|
||||
start_gpsd,
|
||||
start_gpsd_daemon,
|
||||
stop_gps,
|
||||
stop_gpsd_daemon,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
@@ -58,10 +62,9 @@ def auto_connect_gps():
|
||||
Automatically connect to gpsd if available.
|
||||
|
||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
import socket
|
||||
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
@@ -75,21 +78,28 @@ def auto_connect_gps():
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
})
|
||||
|
||||
# Try to connect to gpsd on localhost:2947
|
||||
host = 'localhost'
|
||||
port = 2947
|
||||
|
||||
# First check if gpsd is reachable
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
except Exception:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'gpsd not running'
|
||||
})
|
||||
# If gpsd isn't running, try to detect a device and start it
|
||||
if not is_gpsd_running(host, port):
|
||||
devices = detect_gps_devices()
|
||||
if not devices:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'No GPS device detected'
|
||||
})
|
||||
|
||||
# Try to start gpsd with the first detected device
|
||||
device_path = devices[0]['path']
|
||||
success, msg = start_gpsd_daemon(device_path, host, port)
|
||||
if not success:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': msg,
|
||||
'devices': devices,
|
||||
})
|
||||
logger.info(f"Auto-started gpsd on {device_path}")
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
@@ -118,15 +128,26 @@ def auto_connect_gps():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/devices')
|
||||
def list_gps_devices():
|
||||
"""List detected GPS serial devices."""
|
||||
devices = detect_gps_devices()
|
||||
return jsonify({
|
||||
'devices': devices,
|
||||
'gpsd_running': is_gpsd_running(),
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS client."""
|
||||
"""Stop GPS client and gpsd daemon if we started it."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
reader.remove_sky_callback(_sky_callback)
|
||||
|
||||
stop_gps()
|
||||
stop_gpsd_daemon()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -199,10 +199,16 @@ def start_sensor() -> Response:
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err:
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
|
||||
39
setup.sh
39
setup.sh
@@ -137,6 +137,14 @@ need_sudo() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Refresh sudo credential cache so long-running builds don't trigger
|
||||
# mid-compilation password prompts (which can fail due to TTY issues
|
||||
# inside subshells). Safe to call multiple times.
|
||||
refresh_sudo() {
|
||||
[[ -z "${SUDO:-}" ]] && return 0
|
||||
sudo -v 2>/dev/null || true
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
@@ -388,7 +396,7 @@ install_rtlamr_from_source() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
else
|
||||
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
fi
|
||||
else
|
||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
@@ -430,7 +438,8 @@ install_multimon_ng_from_source_macos() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
else
|
||||
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
fi
|
||||
ok "multimon-ng installed successfully from source"
|
||||
)
|
||||
@@ -471,7 +480,8 @@ install_dsd_from_source() {
|
||||
if [[ -w /usr/local/lib ]]; then
|
||||
make install >/dev/null 2>&1
|
||||
else
|
||||
sudo make install >/dev/null 2>&1
|
||||
refresh_sudo
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
fi
|
||||
else
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
@@ -507,7 +517,8 @@ install_dsd_from_source() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||
else
|
||||
sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
$SUDO make install >/dev/null 2>&1 \
|
||||
@@ -545,7 +556,8 @@ install_dump1090_from_source_macos() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
else
|
||||
sudo install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
fi
|
||||
ok "dump1090 installed successfully from source"
|
||||
else
|
||||
@@ -611,7 +623,8 @@ install_acarsdec_from_source_macos() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
else
|
||||
sudo install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
fi
|
||||
ok "acarsdec installed successfully from source"
|
||||
else
|
||||
@@ -646,7 +659,8 @@ install_aiscatcher_from_source_macos() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||
else
|
||||
sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||
fi
|
||||
ok "AIS-catcher installed successfully from source"
|
||||
else
|
||||
@@ -764,7 +778,8 @@ install_satdump_from_source_macos() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
make install >/dev/null 2>&1
|
||||
else
|
||||
sudo make install >/dev/null 2>&1
|
||||
refresh_sudo
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
fi
|
||||
ok "SatDump installed successfully."
|
||||
else
|
||||
@@ -777,6 +792,14 @@ install_satdump_from_source_macos() {
|
||||
}
|
||||
|
||||
install_macos_packages() {
|
||||
need_sudo
|
||||
|
||||
# Prime sudo credentials upfront so builds don't prompt mid-compilation
|
||||
if [[ -n "${SUDO:-}" ]]; then
|
||||
info "Some tools require sudo to install. You may be prompted for your password."
|
||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=19
|
||||
CURRENT_STEP=0
|
||||
|
||||
|
||||
@@ -4119,7 +4119,7 @@ header h1 .tagline {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
height: 140px;
|
||||
max-height: 340px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -4140,7 +4140,9 @@ header h1 .tagline {
|
||||
|
||||
.bt-detail-body {
|
||||
padding: 8px 10px;
|
||||
height: calc(100% - 30px);
|
||||
height: auto;
|
||||
max-height: calc(100% - 30px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bt-detail-placeholder {
|
||||
@@ -4319,6 +4321,110 @@ header h1 .tagline {
|
||||
color: #9fffd1;
|
||||
}
|
||||
|
||||
/* Service Data Inspector */
|
||||
.bt-detail-service-inspector {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bt-inspector-toggle {
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
padding: 3px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bt-inspector-toggle:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bt-inspector-arrow {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.bt-inspector-arrow.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.bt-inspector-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
padding: 6px 8px;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bt-inspector-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.bt-inspector-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-inspector-label {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-inspector-value {
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* MAC Cluster Badge */
|
||||
.bt-mac-cluster-badge {
|
||||
display: inline-block;
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #f59e0b;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Behavioral Flag Badges */
|
||||
.bt-flag-badge {
|
||||
display: inline-block;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-left: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.bt-flag-badge.persistent {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.bt-flag-badge.beacon-like {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.bt-flag-badge.strong-stable {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* Selected device highlight */
|
||||
.bt-device-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
@@ -4469,14 +4575,15 @@ header h1 .tagline {
|
||||
.bt-row-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bt-row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -4521,13 +4628,25 @@ header h1 .tagline {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.bt-irk-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
.bt-device-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-rssi-container {
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-dot.error {
|
||||
background: #ff4444;
|
||||
box-shadow: 0 0 6px rgba(255, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -36,6 +36,7 @@ const ProximityRadar = (function() {
|
||||
let isHovered = false;
|
||||
let renderPending = false;
|
||||
let renderTimer = null;
|
||||
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
@@ -119,6 +120,36 @@ const ProximityRadar = (function() {
|
||||
|
||||
svg = container.querySelector('svg');
|
||||
|
||||
// Event delegation on the devices group (survives innerHTML rebuilds)
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
|
||||
devicesGroup.addEventListener('click', (e) => {
|
||||
const deviceEl = e.target.closest('.radar-device');
|
||||
if (!deviceEl) return;
|
||||
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
// Lock out re-renders briefly so the DOM stays stable after click
|
||||
interactionLockUntil = Date.now() + 500;
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
|
||||
devicesGroup.addEventListener('mouseenter', (e) => {
|
||||
if (e.target.closest('.radar-device')) {
|
||||
isHovered = true;
|
||||
}
|
||||
}, true); // capture phase so we catch enter on child elements
|
||||
|
||||
devicesGroup.addEventListener('mouseleave', (e) => {
|
||||
if (e.target.closest('.radar-device')) {
|
||||
isHovered = false;
|
||||
if (renderPending) {
|
||||
renderPending = false;
|
||||
renderDevices();
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Add sweep animation
|
||||
animateSweep();
|
||||
}
|
||||
@@ -165,8 +196,8 @@ const ProximityRadar = (function() {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Defer render while user is hovering to prevent DOM rebuild flicker
|
||||
if (isHovered) {
|
||||
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||
if (isHovered || Date.now() < interactionLockUntil) {
|
||||
renderPending = true;
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +260,7 @@ const ProximityRadar = (function() {
|
||||
style="cursor: pointer;">
|
||||
<!-- Invisible hit area to prevent hover flicker -->
|
||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
@@ -244,24 +275,6 @@ const ProximityRadar = (function() {
|
||||
}).join('');
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
|
||||
// Attach event handlers
|
||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const deviceKey = el.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseenter', () => { isHovered = true; });
|
||||
el.addEventListener('mouseleave', () => {
|
||||
isHovered = false;
|
||||
if (renderPending) {
|
||||
renderPending = false;
|
||||
renderDevices();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,19 +358,125 @@ const ProximityRadar = (function() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a specific device on the radar
|
||||
* Highlight a specific device on the radar (in-place update, no full re-render)
|
||||
*/
|
||||
function highlightDevice(deviceKey) {
|
||||
const prev = selectedDeviceKey;
|
||||
selectedDeviceKey = deviceKey;
|
||||
renderDevices();
|
||||
|
||||
if (!svg) { return; }
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
if (!devicesGroup) { return; }
|
||||
|
||||
// Remove highlight from previously selected node
|
||||
if (prev && prev !== deviceKey) {
|
||||
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||
if (oldEl) {
|
||||
oldEl.classList.remove('selected');
|
||||
// Remove animated selection ring
|
||||
const ring = oldEl.querySelector('.radar-select-ring');
|
||||
if (ring) ring.remove();
|
||||
// Restore dot opacity
|
||||
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||
const device = devices.get(prev);
|
||||
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||
dot.setAttribute('stroke-width', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add highlight to newly selected node
|
||||
if (deviceKey) {
|
||||
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
|
||||
if (newEl) {
|
||||
applySelectionToElement(newEl, deviceKey);
|
||||
} else {
|
||||
// Node not in DOM yet; full render needed on next cycle
|
||||
renderDevices();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device highlighting
|
||||
* Apply selection styling to a radar device element in-place
|
||||
*/
|
||||
function applySelectionToElement(el, deviceKey) {
|
||||
el.classList.add('selected');
|
||||
const device = devices.get(deviceKey);
|
||||
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||
|
||||
// Update dot styling
|
||||
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||
dot.setAttribute('fill-opacity', '1');
|
||||
dot.setAttribute('stroke', '#00d4ff');
|
||||
dot.setAttribute('stroke-width', '2');
|
||||
}
|
||||
|
||||
// Add animated selection ring if not already present
|
||||
if (!el.querySelector('.radar-select-ring')) {
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const ring = document.createElementNS(ns, 'circle');
|
||||
ring.classList.add('radar-select-ring');
|
||||
ring.setAttribute('r', dotSize + 8);
|
||||
ring.setAttribute('fill', 'none');
|
||||
ring.setAttribute('stroke', '#00d4ff');
|
||||
ring.setAttribute('stroke-width', '2');
|
||||
ring.setAttribute('stroke-opacity', '0.8');
|
||||
|
||||
const animR = document.createElementNS(ns, 'animate');
|
||||
animR.setAttribute('attributeName', 'r');
|
||||
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
|
||||
animR.setAttribute('dur', '1.5s');
|
||||
animR.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(animR);
|
||||
|
||||
const animO = document.createElementNS(ns, 'animate');
|
||||
animO.setAttribute('attributeName', 'stroke-opacity');
|
||||
animO.setAttribute('values', '0.8;0.4;0.8');
|
||||
animO.setAttribute('dur', '1.5s');
|
||||
animO.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(animO);
|
||||
|
||||
// Insert after the hit area
|
||||
const hitArea = el.querySelector('.radar-device-hitarea');
|
||||
if (hitArea && hitArea.nextSibling) {
|
||||
el.insertBefore(ring, hitArea.nextSibling);
|
||||
} else {
|
||||
el.insertBefore(ring, el.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device highlighting (in-place update, no full re-render)
|
||||
*/
|
||||
function clearHighlight() {
|
||||
const prev = selectedDeviceKey;
|
||||
selectedDeviceKey = null;
|
||||
renderDevices();
|
||||
|
||||
if (!svg || !prev) { return; }
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
if (!devicesGroup) { return; }
|
||||
|
||||
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||
if (oldEl) {
|
||||
oldEl.classList.remove('selected');
|
||||
const ring = oldEl.querySelector('.radar-select-ring');
|
||||
if (ring) ring.remove();
|
||||
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||
const device = devices.get(prev);
|
||||
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||
dot.setAttribute('stroke-width', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -302,7 +302,13 @@ const SignalCards = (function() {
|
||||
*/
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
let date = new Date(timestamp);
|
||||
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
|
||||
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
|
||||
const today = new Date();
|
||||
date = new Date(today.toDateString() + ' ' + timestamp);
|
||||
}
|
||||
if (isNaN(date.getTime())) return timestamp;
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
|
||||
|
||||
@@ -356,7 +356,9 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Update panel elements
|
||||
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
||||
document.getElementById('btDetailAddress').textContent = device.address;
|
||||
document.getElementById('btDetailAddress').textContent = isUuidAddress(device)
|
||||
? 'CB: ' + device.address
|
||||
: device.address;
|
||||
|
||||
// RSSI
|
||||
const rssiEl = document.getElementById('btDetailRssi');
|
||||
@@ -458,8 +460,98 @@ const BluetoothMode = (function() {
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
|
||||
// New stat cells
|
||||
document.getElementById('btDetailTxPower').textContent = device.tx_power != null
|
||||
? device.tx_power + ' dBm' : '--';
|
||||
document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null
|
||||
? device.seen_rate.toFixed(1) + '/min' : '--';
|
||||
|
||||
// Stability from variance
|
||||
const stabilityEl = document.getElementById('btDetailStability');
|
||||
if (device.rssi_variance != null) {
|
||||
let stabLabel, stabColor;
|
||||
if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; }
|
||||
else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; }
|
||||
else { stabLabel = 'Unstable'; stabColor = '#ef4444'; }
|
||||
stabilityEl.textContent = stabLabel;
|
||||
stabilityEl.style.color = stabColor;
|
||||
} else {
|
||||
stabilityEl.textContent = '--';
|
||||
stabilityEl.style.color = '';
|
||||
}
|
||||
|
||||
// Distance with confidence
|
||||
const distEl = document.getElementById('btDetailDistance');
|
||||
if (device.estimated_distance_m != null) {
|
||||
const confPct = Math.round((device.distance_confidence || 0) * 100);
|
||||
distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%';
|
||||
} else {
|
||||
distEl.textContent = '--';
|
||||
}
|
||||
|
||||
// Appearance badge
|
||||
if (device.appearance_name) {
|
||||
badgesHtml += '<span class="bt-detail-badge flag">' + escapeHtml(device.appearance_name) + '</span>';
|
||||
badgesEl.innerHTML = badgesHtml;
|
||||
}
|
||||
|
||||
// MAC cluster indicator
|
||||
const macClusterEl = document.getElementById('btDetailMacCluster');
|
||||
if (macClusterEl) {
|
||||
if (device.mac_cluster_count > 1) {
|
||||
macClusterEl.textContent = device.mac_cluster_count + ' MACs';
|
||||
macClusterEl.style.display = '';
|
||||
} else {
|
||||
macClusterEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Service data inspector
|
||||
const inspectorEl = document.getElementById('btDetailServiceInspector');
|
||||
const inspectorContent = document.getElementById('btInspectorContent');
|
||||
if (inspectorEl && inspectorContent) {
|
||||
const hasData = device.manufacturer_bytes || device.appearance != null ||
|
||||
(device.service_data && Object.keys(device.service_data).length > 0);
|
||||
if (hasData) {
|
||||
inspectorEl.style.display = '';
|
||||
let inspHtml = '';
|
||||
if (device.appearance != null) {
|
||||
const name = device.appearance_name || '';
|
||||
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Appearance</span><span class="bt-inspector-value">0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '</span></div>';
|
||||
}
|
||||
if (device.manufacturer_bytes) {
|
||||
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Mfr Data</span><span class="bt-inspector-value">' + escapeHtml(device.manufacturer_bytes) + '</span></div>';
|
||||
}
|
||||
if (device.service_data) {
|
||||
Object.entries(device.service_data).forEach(([uuid, hex]) => {
|
||||
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">' + escapeHtml(uuid) + '</span><span class="bt-inspector-value">' + escapeHtml(hex) + '</span></div>';
|
||||
});
|
||||
}
|
||||
inspectorContent.innerHTML = inspHtml;
|
||||
} else {
|
||||
inspectorEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateWatchlistButton(device);
|
||||
|
||||
// IRK
|
||||
const irkContainer = document.getElementById('btDetailIrk');
|
||||
if (irkContainer) {
|
||||
if (device.has_irk) {
|
||||
irkContainer.style.display = 'block';
|
||||
const irkVal = document.getElementById('btDetailIrkValue');
|
||||
if (irkVal) {
|
||||
const label = device.irk_source_name
|
||||
? device.irk_source_name + ' — ' + device.irk_hex
|
||||
: device.irk_hex;
|
||||
irkVal.textContent = label;
|
||||
}
|
||||
} else {
|
||||
irkContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Services
|
||||
const servicesContainer = document.getElementById('btDetailServices');
|
||||
const servicesList = document.getElementById('btDetailServicesList');
|
||||
@@ -600,9 +692,25 @@ const BluetoothMode = (function() {
|
||||
if (parts.length === 6) {
|
||||
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
||||
}
|
||||
// CoreBluetooth UUID format (8-4-4-4-12)
|
||||
if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) {
|
||||
return address.substring(0, 8) + '...';
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
function isUuidAddress(device) {
|
||||
return device.address_type === 'uuid';
|
||||
}
|
||||
|
||||
function formatAddress(device) {
|
||||
if (!device || !device.address) return '--';
|
||||
if (isUuidAddress(device)) {
|
||||
return device.address.substring(0, 8) + '-...' + device.address.slice(-4);
|
||||
}
|
||||
return device.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check system capabilities
|
||||
*/
|
||||
@@ -660,6 +768,12 @@ const BluetoothMode = (function() {
|
||||
hideCapabilityWarning();
|
||||
}
|
||||
|
||||
// Show/hide Ubertooth option based on capabilities
|
||||
const ubertoothOption = document.getElementById('btScanModeUbertooth');
|
||||
if (ubertoothOption) {
|
||||
ubertoothOption.style.display = data.has_ubertooth ? '' : 'none';
|
||||
}
|
||||
|
||||
if (scanModeSelect && data.preferred_backend) {
|
||||
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
||||
if (option) option.selected = true;
|
||||
@@ -1085,7 +1199,7 @@ const BluetoothMode = (function() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' +
|
||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
|
||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
||||
'</div>' +
|
||||
evidenceHtml +
|
||||
@@ -1142,7 +1256,7 @@ const BluetoothMode = (function() {
|
||||
|
||||
const displayName = device.name || formatDeviceId(device.address);
|
||||
const name = escapeHtml(displayName);
|
||||
const addr = escapeHtml(device.address || 'Unknown');
|
||||
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||
const seenCount = device.seen_count || 0;
|
||||
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
||||
@@ -1167,6 +1281,12 @@ const BluetoothMode = (function() {
|
||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||
}
|
||||
|
||||
// IRK badge - show if paired IRK is available
|
||||
let irkBadge = '';
|
||||
if (device.has_irk) {
|
||||
irkBadge = '<span class="bt-irk-badge">IRK</span>';
|
||||
}
|
||||
|
||||
// Risk badge - show if risk score is significant
|
||||
let riskBadge = '';
|
||||
if (riskScore >= 0.3) {
|
||||
@@ -1184,9 +1304,36 @@ const BluetoothMode = (function() {
|
||||
statusDot = '<span class="bt-status-dot known"></span>';
|
||||
}
|
||||
|
||||
// Distance display
|
||||
const distM = device.estimated_distance_m;
|
||||
let distStr = '';
|
||||
if (distM != null) {
|
||||
distStr = '~' + distM.toFixed(1) + 'm';
|
||||
}
|
||||
|
||||
// Behavioral flag badges
|
||||
const hFlags = device.heuristic_flags || [];
|
||||
let flagBadges = '';
|
||||
if (device.is_persistent || hFlags.includes('persistent')) {
|
||||
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||
}
|
||||
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
|
||||
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||
}
|
||||
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
|
||||
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||
}
|
||||
|
||||
// MAC cluster badge
|
||||
let clusterBadge = '';
|
||||
if (device.mac_cluster_count > 1) {
|
||||
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
|
||||
}
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
if (distStr) secondaryParts.push(distStr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
// Add agent name if not Local
|
||||
@@ -1205,7 +1352,10 @@ const BluetoothMode = (function() {
|
||||
protoBadge +
|
||||
'<span class="bt-device-name">' + name + '</span>' +
|
||||
trackerBadge +
|
||||
irkBadge +
|
||||
riskBadge +
|
||||
flagBadges +
|
||||
clusterBadge +
|
||||
'</div>' +
|
||||
'<div class="bt-row-right">' +
|
||||
'<div class="bt-rssi-container">' +
|
||||
@@ -1300,6 +1450,18 @@ const BluetoothMode = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the service data inspector panel
|
||||
*/
|
||||
function toggleServiceInspector() {
|
||||
const content = document.getElementById('btInspectorContent');
|
||||
const arrow = document.getElementById('btInspectorArrow');
|
||||
if (!content) return;
|
||||
const open = content.style.display === 'none';
|
||||
content.style.display = open ? '' : 'none';
|
||||
if (arrow) arrow.classList.toggle('open', open);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Agent Handling
|
||||
// ==========================================================================
|
||||
@@ -1425,9 +1587,15 @@ const BluetoothMode = (function() {
|
||||
BtLocate.handoff({
|
||||
device_id: device.device_id,
|
||||
mac_address: device.address,
|
||||
address_type: device.address_type || null,
|
||||
irk_hex: device.irk_hex || null,
|
||||
known_name: device.name || null,
|
||||
known_manufacturer: device.manufacturer_name || null,
|
||||
last_known_rssi: device.rssi_current
|
||||
last_known_rssi: device.rssi_current,
|
||||
tx_power: device.tx_power || null,
|
||||
appearance_name: device.appearance_name || null,
|
||||
fingerprint_id: device.fingerprint_id || null,
|
||||
mac_cluster_count: device.mac_cluster_count || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1447,6 +1615,7 @@ const BluetoothMode = (function() {
|
||||
toggleWatchlist,
|
||||
locateDevice,
|
||||
locateById,
|
||||
toggleServiceInspector,
|
||||
|
||||
// Agent handling
|
||||
handleAgentChange,
|
||||
|
||||
@@ -322,7 +322,8 @@ const BtLocate = (function() {
|
||||
const t = data.target;
|
||||
const name = t.known_name || t.name_pattern || '';
|
||||
const addr = t.mac_address || t.device_id || '';
|
||||
targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--';
|
||||
const addrDisplay = formatAddr(addr);
|
||||
targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--';
|
||||
}
|
||||
|
||||
// Environment info
|
||||
@@ -602,6 +603,16 @@ const BtLocate = (function() {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function isUuid(addr) {
|
||||
return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr);
|
||||
}
|
||||
|
||||
function formatAddr(addr) {
|
||||
if (!addr) return '';
|
||||
if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4);
|
||||
return addr;
|
||||
}
|
||||
|
||||
function handoff(deviceInfo) {
|
||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||
handoffData = deviceInfo;
|
||||
@@ -617,15 +628,21 @@ const BtLocate = (function() {
|
||||
const nameEl = document.getElementById('btLocateHandoffName');
|
||||
const metaEl = document.getElementById('btLocateHandoffMeta');
|
||||
if (card) card.style.display = '';
|
||||
if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown';
|
||||
if (nameEl) nameEl.textContent = deviceInfo.known_name || formatAddr(deviceInfo.mac_address) || 'Unknown';
|
||||
if (metaEl) {
|
||||
const parts = [];
|
||||
if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address);
|
||||
if (deviceInfo.mac_address) parts.push(formatAddr(deviceInfo.mac_address));
|
||||
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
|
||||
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
|
||||
metaEl.textContent = parts.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
// Auto-fill IRK if available from scanner
|
||||
if (deviceInfo.irk_hex) {
|
||||
const irkInput = document.getElementById('btLocateIrk');
|
||||
if (irkInput) irkInput.value = deviceInfo.irk_hex;
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate');
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
const GPS = (function() {
|
||||
let eventSource = null;
|
||||
let connected = false;
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
@@ -26,6 +25,7 @@ const GPS = (function() {
|
||||
}
|
||||
|
||||
function connect() {
|
||||
updateConnectionUI(false, false, 'connecting');
|
||||
fetch('/gps/auto-connect', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -40,23 +40,24 @@ const GPS = (function() {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
startStream();
|
||||
subscribeToStream();
|
||||
// Ensure the global GPS stream is running
|
||||
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
||||
startGpsStream();
|
||||
}
|
||||
} else {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?');
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
unsubscribeFromStream();
|
||||
fetch('/gps/stop', { method: 'POST' })
|
||||
.then(() => {
|
||||
connected = false;
|
||||
@@ -64,36 +65,36 @@ const GPS = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
function onGpsStreamData(data) {
|
||||
if (!connected) return;
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
} else if (data.type === 'sky') {
|
||||
lastSky = data;
|
||||
updateSkyUI(data);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToStream() {
|
||||
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||
if (typeof addGpsStreamSubscriber === 'function') {
|
||||
addGpsStreamSubscriber(onGpsStreamData);
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribeFromStream() {
|
||||
if (typeof removeGpsStreamSubscriber === 'function') {
|
||||
removeGpsStreamSubscriber(onGpsStreamData);
|
||||
}
|
||||
eventSource = new EventSource('/gps/stream');
|
||||
eventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
} else if (data.type === 'sky') {
|
||||
lastSky = data;
|
||||
updateSkyUI(data);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
eventSource.onerror = function() {
|
||||
// Reconnect handled by browser automatically
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// UI Updates
|
||||
// ========================
|
||||
|
||||
function updateConnectionUI(isConnected, hasFix) {
|
||||
function updateConnectionUI(isConnected, hasFix, state, message) {
|
||||
const dot = document.getElementById('gpsStatusDot');
|
||||
const text = document.getElementById('gpsStatusText');
|
||||
const connectBtn = document.getElementById('gpsConnectBtn');
|
||||
@@ -102,15 +103,22 @@ const GPS = (function() {
|
||||
|
||||
if (dot) {
|
||||
dot.className = 'gps-status-dot';
|
||||
if (isConnected && hasFix) dot.classList.add('connected');
|
||||
if (state === 'connecting') dot.classList.add('waiting');
|
||||
else if (state === 'error') dot.classList.add('error');
|
||||
else if (isConnected && hasFix) dot.classList.add('connected');
|
||||
else if (isConnected) dot.classList.add('waiting');
|
||||
}
|
||||
if (text) {
|
||||
if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||
if (state === 'connecting') text.textContent = 'Connecting...';
|
||||
else if (state === 'error') text.textContent = message || 'Connection failed';
|
||||
else if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||
else if (isConnected) text.textContent = 'Connected (No Fix)';
|
||||
else text.textContent = 'Disconnected';
|
||||
}
|
||||
if (connectBtn) connectBtn.style.display = isConnected ? 'none' : '';
|
||||
if (connectBtn) {
|
||||
connectBtn.style.display = isConnected ? 'none' : '';
|
||||
connectBtn.disabled = state === 'connecting';
|
||||
}
|
||||
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
||||
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
||||
}
|
||||
@@ -386,10 +394,7 @@ const GPS = (function() {
|
||||
// ========================
|
||||
|
||||
function destroy() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
unsubscribeFromStream();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -3869,7 +3869,9 @@ sudo make install</code>
|
||||
}
|
||||
|
||||
function startAcars() {
|
||||
const device = document.getElementById('acarsDeviceSelect').value;
|
||||
const acarsSelect = document.getElementById('acarsDeviceSelect');
|
||||
const device = acarsSelect.value;
|
||||
const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
|
||||
const frequencies = getAcarsRegionFreqs();
|
||||
|
||||
// Check if using agent mode
|
||||
@@ -3895,7 +3897,7 @@ sudo make install</code>
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device, frequencies, gain: '40' })
|
||||
body: JSON.stringify({ device, frequencies, gain: '40', sdr_type })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -4101,6 +4103,7 @@ sudo make install</code>
|
||||
devices.forEach((d, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.index || i;
|
||||
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
|
||||
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
@@ -4880,6 +4883,7 @@ sudo make install</code>
|
||||
devices.forEach(device => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.index;
|
||||
opt.dataset.sdrType = device.sdr_type || 'rtlsdr';
|
||||
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
@@ -216,6 +216,10 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
|
||||
<span class="mode-name">Bluetooth</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('bt_locate')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg></span>
|
||||
<span class="mode-name">BT Locate</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -439,6 +443,7 @@
|
||||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
|
||||
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
|
||||
<option value="rtlsdr">RTL-SDR</option>
|
||||
<option value="sdrplay">SDRplay</option>
|
||||
<option value="limesdr">LimeSDR</option>
|
||||
<option value="hackrf">HackRF</option>
|
||||
<option value="airspy">Airspy</option>
|
||||
@@ -790,7 +795,10 @@
|
||||
<div class="bt-detail-top-row">
|
||||
<div class="bt-detail-identity">
|
||||
<div class="bt-detail-name" id="btDetailName">Device Name</div>
|
||||
<div class="bt-detail-address" id="btDetailAddress">00:00:00:00:00:00</div>
|
||||
<div class="bt-detail-address">
|
||||
<span id="btDetailAddress">00:00:00:00:00:00</span>
|
||||
<span class="bt-mac-cluster-badge" id="btDetailMacCluster" style="display:none;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-detail-rssi-display">
|
||||
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
|
||||
@@ -831,8 +839,36 @@
|
||||
<span class="bt-detail-stat-label">Mfr ID</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">TX Power</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailTxPower">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Seen Rate</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailSeenRate">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Stability</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailStability">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Distance</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailDistance">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Service Data Inspector (collapsible) -->
|
||||
<div class="bt-detail-service-inspector" id="btDetailServiceInspector" style="display:none;">
|
||||
<div class="bt-inspector-toggle" onclick="BluetoothMode.toggleServiceInspector()">
|
||||
<span class="bt-inspector-arrow" id="btInspectorArrow">▸</span> Raw Data
|
||||
</div>
|
||||
<div class="bt-inspector-content" id="btInspectorContent" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-detail-bottom-row">
|
||||
<div class="bt-detail-irk" id="btDetailIrk" style="display: none;">
|
||||
<span class="bt-irk-badge">IRK</span>
|
||||
<span class="bt-detail-irk-value" id="btDetailIrkValue" style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono);margin-left:6px;word-break:break-all;"></span>
|
||||
</div>
|
||||
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
||||
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
||||
</div>
|
||||
@@ -4942,8 +4978,10 @@
|
||||
// SDR hardware capabilities
|
||||
const sdrCapabilities = {
|
||||
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
|
||||
'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 },
|
||||
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
|
||||
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 }
|
||||
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 },
|
||||
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 }
|
||||
};
|
||||
|
||||
// Current device list with SDR type info
|
||||
@@ -10170,6 +10208,20 @@
|
||||
|
||||
let gpsReconnectTimeout = null;
|
||||
|
||||
// GPS subscriber callbacks - modules can register to receive GPS stream data
|
||||
const gpsStreamSubscribers = [];
|
||||
|
||||
function addGpsStreamSubscriber(fn) {
|
||||
if (!gpsStreamSubscribers.includes(fn)) {
|
||||
gpsStreamSubscribers.push(fn);
|
||||
}
|
||||
}
|
||||
|
||||
function removeGpsStreamSubscriber(fn) {
|
||||
const idx = gpsStreamSubscribers.indexOf(fn);
|
||||
if (idx !== -1) gpsStreamSubscribers.splice(idx, 1);
|
||||
}
|
||||
|
||||
function startGpsStream() {
|
||||
if (gpsEventSource) {
|
||||
gpsEventSource.close();
|
||||
@@ -10187,6 +10239,8 @@
|
||||
gpsLastPosition = data;
|
||||
updateLocationFromGps(data);
|
||||
}
|
||||
// Dispatch to all subscribers (e.g. GPS mode UI)
|
||||
gpsStreamSubscribers.forEach(fn => fn(data));
|
||||
} catch (e) {
|
||||
console.error('GPS parse error:', e);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<option value="bleak">Bleak Library</option>
|
||||
<option value="hcitool">hcitool (Linux)</option>
|
||||
<option value="bluetoothctl">bluetoothctl (Linux)</option>
|
||||
<option value="ubertooth" id="btScanModeUbertooth" style="display:none;">Ubertooth One</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
utils/gps.py
178
utils/gps.py
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user