mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
feat: Add BT Locate and GPS modes with IRK auto-detection
New modes: - BT Locate: SAR Bluetooth device location with GPS-tagged signal trail, RSSI-based proximity bands, audio alerts, and IRK auto-extraction from paired devices (macOS plist / Linux BlueZ) - GPS: Real-time position tracking with live map, speed, heading, altitude, satellite info, and track recording via gpsd Bug fixes: - Fix ABBA deadlock between session lock and aggregator lock in BT Locate - Fix bleak scan lifecycle tracking in BluetoothScanner (is_scanning property now cross-checks backend state) - Fix map tile persistence when switching modes - Use 15s max_age window for fresh detections in BT Locate poll loop Documentation: - Update README, FEATURES.md, USAGE.md, and GitHub Pages with new modes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,8 @@ Support the developer of this open-source project
|
||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
|
||||
@@ -188,6 +188,52 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **Proximity radar** visualization
|
||||
- **Device type breakdown** chart
|
||||
|
||||
## BT Locate (SAR Bluetooth Device Location)
|
||||
|
||||
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
||||
|
||||
### Core Features
|
||||
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
||||
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
||||
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
||||
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
||||
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
||||
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
||||
- **Distance estimation** - Log-distance path loss model with environment presets
|
||||
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
||||
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
||||
|
||||
### Environment Presets
|
||||
- **Open Field** (n=2.0) - Free space path loss
|
||||
- **Outdoor** (n=2.2) - Typical outdoor environment
|
||||
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
||||
|
||||
### Map & Trail
|
||||
- Interactive Leaflet map with GPS trail visualization
|
||||
- Trail points color-coded by proximity band
|
||||
- Polyline connecting detection points for path visualization
|
||||
- Supports user-configured tile providers
|
||||
|
||||
### Requirements
|
||||
- Bluetooth adapter (built-in or USB)
|
||||
- GPS receiver (optional, falls back to manual coordinates)
|
||||
|
||||
## GPS Mode
|
||||
|
||||
Real-time GPS position tracking with live map visualization.
|
||||
|
||||
### Features
|
||||
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
||||
- **Interactive map** - Current position on Leaflet map with track history
|
||||
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
||||
- **Satellite info** - Number of satellites in view and fix quality
|
||||
- **Track recording** - Record GPS tracks with export capability
|
||||
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
||||
|
||||
### Requirements
|
||||
- USB GPS receiver connected via gpsd
|
||||
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
||||
|
||||
## TSCM Counter-Surveillance Mode
|
||||
|
||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||
|
||||
@@ -235,6 +235,54 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
|
||||
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
|
||||
|
||||
## BT Locate (SAR Device Location)
|
||||
|
||||
1. **Set Target** - Enter one or more target identifiers:
|
||||
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
|
||||
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
|
||||
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
|
||||
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
|
||||
2. **Choose Environment** - Select the RF environment preset:
|
||||
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
|
||||
- **Indoor** (n=3.0) - For buildings with walls and obstacles
|
||||
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||
4. **Monitor HUD** - The proximity display shows:
|
||||
- Proximity band (IMMEDIATE / NEAR / FAR)
|
||||
- Estimated distance in meters
|
||||
- Raw RSSI and smoothed RSSI average
|
||||
- Detection count and GPS-tagged points
|
||||
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
|
||||
7. **Review Trail** - Check the map for GPS-tagged detection trail
|
||||
|
||||
### Hand-off from Bluetooth Mode
|
||||
|
||||
1. Open Bluetooth scanning mode and find the target device
|
||||
2. Click the "Locate" button on the device card
|
||||
3. BT Locate opens with the device pre-filled
|
||||
4. Click "Start Locate" to begin tracking
|
||||
|
||||
### Tips
|
||||
|
||||
- For devices with address randomization (iPhones, modern Android), use the IRK method
|
||||
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
|
||||
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
|
||||
- Clear the trail when starting a new search area
|
||||
|
||||
## GPS Mode
|
||||
|
||||
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
||||
2. **View Map** - Your position appears on the interactive map with a track trail
|
||||
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
|
||||
4. **Record Track** - Enable track recording to save your path
|
||||
|
||||
### Tips
|
||||
|
||||
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
|
||||
- GPS fix may take 30-60 seconds after cold start
|
||||
- Accuracy improves with more satellites in view
|
||||
|
||||
## Meshtastic
|
||||
|
||||
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||
|
||||
BIN
docs/images/bt-locate.png
Normal file
BIN
docs/images/bt-locate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -107,6 +107,18 @@
|
||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📍</div>
|
||||
<h3>BT Locate</h3>
|
||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>TSCM</h3>
|
||||
@@ -236,6 +248,10 @@
|
||||
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -838,15 +838,15 @@ class ModeManager:
|
||||
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
|
||||
elif mode == 'aprs':
|
||||
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
|
||||
elif mode == 'tscm':
|
||||
data['data'] = {
|
||||
'anomalies': getattr(self, 'tscm_anomalies', []),
|
||||
'baseline': getattr(self, 'tscm_baseline', {}),
|
||||
'wifi_devices': list(self.wifi_networks.values()),
|
||||
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
||||
'bt_devices': list(self.bluetooth_devices.values()),
|
||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
||||
}
|
||||
elif mode == 'tscm':
|
||||
data['data'] = {
|
||||
'anomalies': getattr(self, 'tscm_anomalies', []),
|
||||
'baseline': getattr(self, 'tscm_baseline', {}),
|
||||
'wifi_devices': list(self.wifi_networks.values()),
|
||||
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
||||
'bt_devices': list(self.bluetooth_devices.values()),
|
||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
||||
}
|
||||
elif mode == 'listening_post':
|
||||
data['data'] = {
|
||||
'activity': getattr(self, 'listening_post_activity', []),
|
||||
@@ -1105,24 +1105,24 @@ class ModeManager:
|
||||
self.wifi_clients.clear()
|
||||
elif mode == 'bluetooth':
|
||||
self.bluetooth_devices.clear()
|
||||
elif mode == 'tscm':
|
||||
# Clean up TSCM sub-threads
|
||||
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
|
||||
if sub_thread_name in self.output_threads:
|
||||
thread = self.output_threads[sub_thread_name]
|
||||
if thread and thread.is_alive():
|
||||
thread.join(timeout=2)
|
||||
del self.output_threads[sub_thread_name]
|
||||
# Clear TSCM data
|
||||
self.tscm_anomalies = []
|
||||
self.tscm_baseline = {}
|
||||
self.tscm_rf_signals = []
|
||||
self.tscm_wifi_clients = {}
|
||||
# Clear reported threat tracking sets
|
||||
if hasattr(self, '_tscm_reported_wifi'):
|
||||
self._tscm_reported_wifi.clear()
|
||||
if hasattr(self, '_tscm_reported_bt'):
|
||||
self._tscm_reported_bt.clear()
|
||||
elif mode == 'tscm':
|
||||
# Clean up TSCM sub-threads
|
||||
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
|
||||
if sub_thread_name in self.output_threads:
|
||||
thread = self.output_threads[sub_thread_name]
|
||||
if thread and thread.is_alive():
|
||||
thread.join(timeout=2)
|
||||
del self.output_threads[sub_thread_name]
|
||||
# Clear TSCM data
|
||||
self.tscm_anomalies = []
|
||||
self.tscm_baseline = {}
|
||||
self.tscm_rf_signals = []
|
||||
self.tscm_wifi_clients = {}
|
||||
# Clear reported threat tracking sets
|
||||
if hasattr(self, '_tscm_reported_wifi'):
|
||||
self._tscm_reported_wifi.clear()
|
||||
if hasattr(self, '_tscm_reported_bt'):
|
||||
self._tscm_reported_bt.clear()
|
||||
elif mode == 'dsc':
|
||||
# Clear DSC data
|
||||
if hasattr(self, 'dsc_messages'):
|
||||
@@ -1542,10 +1542,10 @@ class ModeManager:
|
||||
def _start_wifi(self, params: dict) -> dict:
|
||||
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
||||
interface = params.get('interface')
|
||||
channel = params.get('channel')
|
||||
channels = params.get('channels')
|
||||
band = params.get('band', 'abg')
|
||||
scan_type = params.get('scan_type', 'deep')
|
||||
channel = params.get('channel')
|
||||
channels = params.get('channels')
|
||||
band = params.get('band', 'abg')
|
||||
scan_type = params.get('scan_type', 'deep')
|
||||
|
||||
# Handle quick scan - returns results synchronously
|
||||
if scan_type == 'quick':
|
||||
@@ -1574,21 +1574,21 @@ class ModeManager:
|
||||
else:
|
||||
scan_band = 'all'
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [int(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'message': 'Invalid channels'}
|
||||
|
||||
# Start deep scan
|
||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [int(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'message': 'Invalid channels'}
|
||||
|
||||
# Start deep scan
|
||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
||||
# Start thread to sync data to agent's dictionaries
|
||||
thread = threading.Thread(
|
||||
target=self._wifi_data_sync,
|
||||
@@ -1607,12 +1607,12 @@ class ModeManager:
|
||||
else:
|
||||
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
|
||||
|
||||
except ImportError:
|
||||
# Fallback to direct airodump-ng
|
||||
return self._start_wifi_fallback(interface, channel, band, channels)
|
||||
except Exception as e:
|
||||
logger.error(f"WiFi scanner error: {e}")
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
except ImportError:
|
||||
# Fallback to direct airodump-ng
|
||||
return self._start_wifi_fallback(interface, channel, band, channels)
|
||||
except Exception as e:
|
||||
logger.error(f"WiFi scanner error: {e}")
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
def _wifi_data_sync(self, scanner):
|
||||
"""Sync WiFi scanner data to agent's data structures."""
|
||||
@@ -1646,14 +1646,14 @@ class ModeManager:
|
||||
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
||||
self._wifi_scanner_instance.stop_deep_scan()
|
||||
|
||||
def _start_wifi_fallback(
|
||||
self,
|
||||
interface: str | None,
|
||||
channel: int | None,
|
||||
band: str,
|
||||
channels: list[int] | str | None = None,
|
||||
) -> dict:
|
||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||
def _start_wifi_fallback(
|
||||
self,
|
||||
interface: str | None,
|
||||
channel: int | None,
|
||||
band: str,
|
||||
channels: list[int] | str | None = None,
|
||||
) -> dict:
|
||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||
if not interface:
|
||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
||||
|
||||
@@ -1680,23 +1680,23 @@ class ModeManager:
|
||||
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
||||
if gps_manager.is_running:
|
||||
cmd.append('--gpsd')
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [int(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'message': 'Invalid channels'}
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [int(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'message': 'Invalid channels'}
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
cmd.append(interface)
|
||||
|
||||
try:
|
||||
@@ -2022,7 +2022,7 @@ class ModeManager:
|
||||
'agent_gps': gps_manager.position
|
||||
}
|
||||
|
||||
scanner.set_on_device_updated(on_device_updated)
|
||||
scanner.add_device_callback(on_device_updated)
|
||||
|
||||
# Start scanning
|
||||
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
||||
@@ -3148,21 +3148,21 @@ class ModeManager:
|
||||
self.tscm_baseline = {}
|
||||
if not hasattr(self, 'tscm_anomalies'):
|
||||
self.tscm_anomalies = []
|
||||
if not hasattr(self, 'tscm_rf_signals'):
|
||||
self.tscm_rf_signals = []
|
||||
if not hasattr(self, 'tscm_wifi_clients'):
|
||||
self.tscm_wifi_clients = {}
|
||||
self.tscm_anomalies.clear()
|
||||
self.tscm_wifi_clients.clear()
|
||||
if not hasattr(self, 'tscm_rf_signals'):
|
||||
self.tscm_rf_signals = []
|
||||
if not hasattr(self, 'tscm_wifi_clients'):
|
||||
self.tscm_wifi_clients = {}
|
||||
self.tscm_anomalies.clear()
|
||||
self.tscm_wifi_clients.clear()
|
||||
|
||||
# Get params for what to scan
|
||||
scan_wifi = params.get('wifi', True)
|
||||
scan_bt = params.get('bluetooth', True)
|
||||
scan_rf = params.get('rf', True)
|
||||
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
||||
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
||||
sdr_device = params.get('sdr_device', params.get('device', 0))
|
||||
sweep_type = params.get('sweep_type')
|
||||
scan_rf = params.get('rf', True)
|
||||
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
||||
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
||||
sdr_device = params.get('sdr_device', params.get('device', 0))
|
||||
sweep_type = params.get('sweep_type')
|
||||
|
||||
# Get baseline_id for comparison (same as local mode)
|
||||
baseline_id = params.get('baseline_id')
|
||||
@@ -3170,11 +3170,11 @@ class ModeManager:
|
||||
started_scans = []
|
||||
|
||||
# Start the combined TSCM scanner thread using existing Intercept functions
|
||||
thread = threading.Thread(
|
||||
target=self._tscm_scanner_thread,
|
||||
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
|
||||
daemon=True
|
||||
)
|
||||
thread = threading.Thread(
|
||||
target=self._tscm_scanner_thread,
|
||||
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
self.output_threads['tscm'] = thread
|
||||
|
||||
@@ -3193,9 +3193,9 @@ class ModeManager:
|
||||
'scanning': started_scans
|
||||
}
|
||||
|
||||
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
||||
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
||||
baseline_id: int | None = None, sweep_type: str | None = None):
|
||||
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
||||
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
||||
baseline_id: int | None = None, sweep_type: str | None = None):
|
||||
"""Combined TSCM scanner using existing Intercept functions.
|
||||
|
||||
NOTE: This matches local mode behavior exactly:
|
||||
@@ -3208,20 +3208,20 @@ class ModeManager:
|
||||
stop_event = self.stop_events.get(mode)
|
||||
|
||||
# Import existing Intercept TSCM functions
|
||||
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
||||
logger.info("TSCM imports successful")
|
||||
|
||||
sweep_ranges = None
|
||||
if sweep_type:
|
||||
try:
|
||||
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||
sweep_ranges = preset.get('ranges') if preset else None
|
||||
except Exception:
|
||||
sweep_ranges = None
|
||||
|
||||
# Load baseline if specified (same as local mode)
|
||||
baseline = None
|
||||
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
||||
logger.info("TSCM imports successful")
|
||||
|
||||
sweep_ranges = None
|
||||
if sweep_type:
|
||||
try:
|
||||
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||
sweep_ranges = preset.get('ranges') if preset else None
|
||||
except Exception:
|
||||
sweep_ranges = None
|
||||
|
||||
# Load baseline if specified (same as local mode)
|
||||
baseline = None
|
||||
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
||||
baseline = get_tscm_baseline(baseline_id)
|
||||
if baseline:
|
||||
@@ -3242,9 +3242,9 @@ class ModeManager:
|
||||
self._tscm_correlation = None
|
||||
|
||||
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
||||
seen_wifi = {}
|
||||
seen_wifi_clients = {}
|
||||
seen_bt = {}
|
||||
seen_wifi = {}
|
||||
seen_wifi_clients = {}
|
||||
seen_bt = {}
|
||||
|
||||
last_rf_scan = 0
|
||||
rf_scan_interval = 30
|
||||
@@ -3290,63 +3290,63 @@ class ModeManager:
|
||||
enriched['is_new'] = not classification.get('in_baseline', False)
|
||||
enriched['reasons'] = classification.get('reasons', [])
|
||||
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.wifi_networks[bssid] = enriched
|
||||
|
||||
# WiFi clients (monitor mode only)
|
||||
try:
|
||||
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
||||
for client in wifi_clients:
|
||||
mac = (client.get('mac') or '').upper()
|
||||
if not mac or mac in seen_wifi_clients:
|
||||
continue
|
||||
seen_wifi_clients[mac] = client
|
||||
|
||||
rssi_val = client.get('rssi_current')
|
||||
if rssi_val is None:
|
||||
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
||||
|
||||
client_device = {
|
||||
'mac': mac,
|
||||
'vendor': client.get('vendor'),
|
||||
'name': client.get('vendor') or 'WiFi Client',
|
||||
'rssi': rssi_val,
|
||||
'associated_bssid': client.get('associated_bssid'),
|
||||
'probed_ssids': client.get('probed_ssids', []),
|
||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||
'is_client': True,
|
||||
}
|
||||
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
||||
client_device['classification'] = profile.risk_level.value
|
||||
client_device['score'] = profile.total_score
|
||||
client_device['score_modifier'] = profile.score_modifier
|
||||
client_device['known_device'] = profile.known_device
|
||||
client_device['known_device_name'] = profile.known_device_name
|
||||
client_device['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
client_device['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.tscm_wifi_clients[mac] = client_device
|
||||
except Exception as e:
|
||||
logger.debug(f"WiFi client scan error: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"WiFi scan error: {e}")
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.wifi_networks[bssid] = enriched
|
||||
|
||||
# WiFi clients (monitor mode only)
|
||||
try:
|
||||
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
||||
for client in wifi_clients:
|
||||
mac = (client.get('mac') or '').upper()
|
||||
if not mac or mac in seen_wifi_clients:
|
||||
continue
|
||||
seen_wifi_clients[mac] = client
|
||||
|
||||
rssi_val = client.get('rssi_current')
|
||||
if rssi_val is None:
|
||||
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
||||
|
||||
client_device = {
|
||||
'mac': mac,
|
||||
'vendor': client.get('vendor'),
|
||||
'name': client.get('vendor') or 'WiFi Client',
|
||||
'rssi': rssi_val,
|
||||
'associated_bssid': client.get('associated_bssid'),
|
||||
'probed_ssids': client.get('probed_ssids', []),
|
||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||
'is_client': True,
|
||||
}
|
||||
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
||||
client_device['classification'] = profile.risk_level.value
|
||||
client_device['score'] = profile.total_score
|
||||
client_device['score_modifier'] = profile.score_modifier
|
||||
client_device['known_device'] = profile.known_device
|
||||
client_device['known_device_name'] = profile.known_device_name
|
||||
client_device['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
client_device['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.tscm_wifi_clients[mac] = client_device
|
||||
except Exception as e:
|
||||
logger.debug(f"WiFi client scan error: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"WiFi scan error: {e}")
|
||||
|
||||
# Bluetooth scan using Intercept's function (same as local mode)
|
||||
if scan_bt:
|
||||
@@ -3380,18 +3380,18 @@ class ModeManager:
|
||||
enriched['is_new'] = not classification.get('in_baseline', False)
|
||||
enriched['reasons'] = classification.get('reasons', [])
|
||||
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.bluetooth_devices[mac] = enriched
|
||||
except Exception as e:
|
||||
@@ -3402,11 +3402,11 @@ class ModeManager:
|
||||
try:
|
||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
||||
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
||||
rf_signals = _scan_rf_signals(
|
||||
sdr_device,
|
||||
stop_check=agent_stop_check,
|
||||
sweep_ranges=sweep_ranges
|
||||
)
|
||||
rf_signals = _scan_rf_signals(
|
||||
sdr_device,
|
||||
stop_check=agent_stop_check,
|
||||
sweep_ranges=sweep_ranges
|
||||
)
|
||||
|
||||
# Analyze each RF signal like local mode does
|
||||
analyzed_signals = []
|
||||
@@ -3426,17 +3426,17 @@ class ModeManager:
|
||||
analyzed['reasons'] = classification.get('reasons', [])
|
||||
|
||||
# Use correlation engine for scoring (same as local mode)
|
||||
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
||||
analyzed['classification'] = profile.risk_level.value
|
||||
analyzed['score'] = profile.total_score
|
||||
analyzed['score_modifier'] = profile.score_modifier
|
||||
analyzed['known_device'] = profile.known_device
|
||||
analyzed['known_device_name'] = profile.known_device_name
|
||||
analyzed['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
||||
analyzed['classification'] = profile.risk_level.value
|
||||
analyzed['score'] = profile.total_score
|
||||
analyzed['score_modifier'] = profile.score_modifier
|
||||
analyzed['known_device'] = profile.known_device
|
||||
analyzed['known_device_name'] = profile.known_device_name
|
||||
analyzed['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
|
||||
analyzed['is_threat'] = is_threat
|
||||
analyzed_signals.append(analyzed)
|
||||
|
||||
@@ -32,6 +32,9 @@ scapy>=2.4.5
|
||||
# QR code generation for Meshtastic channels (optional)
|
||||
qrcode[pil]>=7.4
|
||||
|
||||
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
|
||||
@@ -33,6 +33,7 @@ def register_blueprints(app):
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
from .bt_locate import bt_locate_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -65,6 +66,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
@@ -7,40 +7,40 @@ aggregation, and heuristics.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, session
|
||||
|
||||
from utils.bluetooth import (
|
||||
BluetoothScanner,
|
||||
BTDeviceAggregate,
|
||||
get_bluetooth_scanner,
|
||||
check_capabilities,
|
||||
RANGE_UNKNOWN,
|
||||
from utils.bluetooth import (
|
||||
BluetoothScanner,
|
||||
BTDeviceAggregate,
|
||||
get_bluetooth_scanner,
|
||||
check_capabilities,
|
||||
RANGE_UNKNOWN,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
get_tracker_engine,
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||
|
||||
# Blueprint
|
||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||
|
||||
# Seen-before tracking
|
||||
_bt_seen_cache: set[str] = set()
|
||||
_bt_session_seen: set[str] = set()
|
||||
_bt_seen_lock = threading.Lock()
|
||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||
|
||||
# Seen-before tracking
|
||||
_bt_seen_cache: set[str] = set()
|
||||
_bt_session_seen: set[str] = set()
|
||||
_bt_seen_lock = threading.Lock()
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE FUNCTIONS
|
||||
@@ -172,20 +172,20 @@ def get_all_baselines() -> list[dict]:
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
|
||||
def save_observation_history(device: BTDeviceAggregate) -> None:
|
||||
"""Save device observation to history."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
|
||||
VALUES (?, ?, ?)
|
||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||
|
||||
|
||||
def load_seen_device_ids() -> set[str]:
|
||||
"""Load distinct device IDs from history for seen-before tracking."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
||||
return {row['device_id'] for row in cursor}
|
||||
def save_observation_history(device: BTDeviceAggregate) -> None:
|
||||
"""Save device observation to history."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
|
||||
VALUES (?, ?, ?)
|
||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||
|
||||
|
||||
def load_seen_device_ids() -> set[str]:
|
||||
"""Load distinct device IDs from history for seen-before tracking."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
||||
return {row['device_id'] for row in cursor}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -206,7 +206,7 @@ def get_capabilities():
|
||||
|
||||
|
||||
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
|
||||
def start_scan():
|
||||
def start_scan():
|
||||
"""
|
||||
Start Bluetooth scanning.
|
||||
|
||||
@@ -236,42 +236,42 @@ def start_scan():
|
||||
# Get scanner instance
|
||||
scanner = get_bluetooth_scanner(adapter_id)
|
||||
|
||||
# Initialize database tables if needed
|
||||
init_bt_tables()
|
||||
|
||||
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
||||
try:
|
||||
with _bt_seen_lock:
|
||||
device.seen_before = device.device_id in _bt_seen_cache
|
||||
if device.device_id not in _bt_session_seen:
|
||||
save_observation_history(device)
|
||||
_bt_session_seen.add(device.device_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"BT seen-before update failed: {e}")
|
||||
|
||||
# Setup seen-before callback
|
||||
if scanner._on_device_updated is None:
|
||||
scanner._on_device_updated = _handle_seen_before
|
||||
|
||||
# Ensure cache is initialized
|
||||
with _bt_seen_lock:
|
||||
if not _bt_seen_cache:
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
# Refresh seen-before cache and reset session set for a new scan
|
||||
with _bt_seen_lock:
|
||||
_bt_seen_cache.clear()
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
_bt_session_seen.clear()
|
||||
|
||||
# Load active baseline if exists
|
||||
# Initialize database tables if needed
|
||||
init_bt_tables()
|
||||
|
||||
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
||||
try:
|
||||
with _bt_seen_lock:
|
||||
device.seen_before = device.device_id in _bt_seen_cache
|
||||
if device.device_id not in _bt_session_seen:
|
||||
save_observation_history(device)
|
||||
_bt_session_seen.add(device.device_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"BT seen-before update failed: {e}")
|
||||
|
||||
# Setup seen-before callback
|
||||
if _handle_seen_before not in scanner._on_device_updated_callbacks:
|
||||
scanner.add_device_callback(_handle_seen_before)
|
||||
|
||||
# Ensure cache is initialized
|
||||
with _bt_seen_lock:
|
||||
if not _bt_seen_cache:
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
# Refresh seen-before cache and reset session set for a new scan
|
||||
with _bt_seen_lock:
|
||||
_bt_seen_cache.clear()
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
_bt_session_seen.clear()
|
||||
|
||||
# Load active baseline if exists
|
||||
baseline_id = get_active_baseline_id()
|
||||
if baseline_id:
|
||||
device_ids = get_baseline_device_ids(baseline_id)
|
||||
@@ -896,15 +896,15 @@ def stream_events():
|
||||
else:
|
||||
return event_type, event
|
||||
|
||||
def event_generator() -> Generator[str, None, None]:
|
||||
"""Generate SSE events from scanner."""
|
||||
for event in scanner.stream_events(timeout=1.0):
|
||||
event_name, event_data = map_event_type(event)
|
||||
try:
|
||||
process_event('bluetooth', event_data, event_name)
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event_data, event=event_name)
|
||||
def event_generator() -> Generator[str, None, None]:
|
||||
"""Generate SSE events from scanner."""
|
||||
for event in scanner.stream_events(timeout=1.0):
|
||||
event_name, event_data = map_event_type(event)
|
||||
try:
|
||||
process_event('bluetooth', event_data, event_name)
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event_data, event=event_name)
|
||||
|
||||
return Response(
|
||||
event_generator(),
|
||||
@@ -988,34 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
devices = scanner.get_devices()
|
||||
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
|
||||
|
||||
# Convert to TSCM format with tracker detection data
|
||||
tscm_devices = []
|
||||
for device in devices:
|
||||
manufacturer_name = device.manufacturer_name
|
||||
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_name = oui_vendor
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
device_data = {
|
||||
'mac': device.address,
|
||||
'address_type': device.address_type,
|
||||
'device_key': device.device_key,
|
||||
'name': device.name or 'Unknown',
|
||||
'rssi': device.rssi_current or -100,
|
||||
'rssi_median': device.rssi_median,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'type': _classify_device_type(device),
|
||||
'manufacturer': manufacturer_name,
|
||||
'manufacturer_id': device.manufacturer_id,
|
||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||
'protocol': device.protocol,
|
||||
'first_seen': device.first_seen.isoformat(),
|
||||
# Convert to TSCM format with tracker detection data
|
||||
tscm_devices = []
|
||||
for device in devices:
|
||||
manufacturer_name = device.manufacturer_name
|
||||
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_name = oui_vendor
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
device_data = {
|
||||
'mac': device.address,
|
||||
'address_type': device.address_type,
|
||||
'device_key': device.device_key,
|
||||
'name': device.name or 'Unknown',
|
||||
'rssi': device.rssi_current or -100,
|
||||
'rssi_median': device.rssi_median,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'type': _classify_device_type(device),
|
||||
'manufacturer': manufacturer_name,
|
||||
'manufacturer_id': device.manufacturer_id,
|
||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||
'protocol': device.protocol,
|
||||
'first_seen': device.first_seen.isoformat(),
|
||||
'last_seen': device.last_seen.isoformat(),
|
||||
'seen_count': device.seen_count,
|
||||
'range_band': device.range_band,
|
||||
@@ -1229,38 +1229,38 @@ def get_device_timeseries(device_key: str):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
"""Classify device type from available data."""
|
||||
name_lower = (device.name or '').lower()
|
||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||
service_uuids = device.service_uuids or []
|
||||
|
||||
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_lower = oui_vendor.lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def normalize_uuid(uuid: str) -> str:
|
||||
if not uuid:
|
||||
return ''
|
||||
value = str(uuid).lower().strip()
|
||||
if value.startswith('0x'):
|
||||
value = value[2:]
|
||||
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
||||
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
||||
return value[4:8]
|
||||
if len(value) == 4:
|
||||
return value
|
||||
return value
|
||||
|
||||
# Check by name patterns
|
||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||
return 'audio'
|
||||
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
"""Classify device type from available data."""
|
||||
name_lower = (device.name or '').lower()
|
||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||
service_uuids = device.service_uuids or []
|
||||
|
||||
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_lower = oui_vendor.lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def normalize_uuid(uuid: str) -> str:
|
||||
if not uuid:
|
||||
return ''
|
||||
value = str(uuid).lower().strip()
|
||||
if value.startswith('0x'):
|
||||
value = value[2:]
|
||||
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
||||
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
||||
return value[4:8]
|
||||
if len(value) == 4:
|
||||
return value
|
||||
return value
|
||||
|
||||
# Check by name patterns
|
||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||
return 'audio'
|
||||
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
|
||||
return 'wearable'
|
||||
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
|
||||
@@ -1269,41 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
return 'computer'
|
||||
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
|
||||
return 'peripheral'
|
||||
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
|
||||
return 'tracker'
|
||||
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
|
||||
return 'speaker'
|
||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||
return 'media'
|
||||
|
||||
# Tracker signals (metadata or Find My service)
|
||||
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
||||
return 'tracker'
|
||||
|
||||
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
||||
if 'fd6f' in normalized_uuids:
|
||||
return 'tracker'
|
||||
|
||||
# Service UUIDs (GATT / classic)
|
||||
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
||||
wearable_uuids = {'180d', '1814', '1816'}
|
||||
hid_uuids = {'1812'}
|
||||
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
||||
|
||||
if normalized_uuids & audio_uuids:
|
||||
return 'audio'
|
||||
if normalized_uuids & hid_uuids:
|
||||
return 'peripheral'
|
||||
if normalized_uuids & wearable_uuids:
|
||||
return 'wearable'
|
||||
if normalized_uuids & beacon_uuids:
|
||||
return 'beacon'
|
||||
|
||||
# Check by manufacturer
|
||||
if 'apple' in manufacturer_lower:
|
||||
return 'apple_device'
|
||||
if 'samsung' in manufacturer_lower:
|
||||
return 'samsung_device'
|
||||
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
|
||||
return 'tracker'
|
||||
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
|
||||
return 'speaker'
|
||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||
return 'media'
|
||||
|
||||
# Tracker signals (metadata or Find My service)
|
||||
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
||||
return 'tracker'
|
||||
|
||||
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
||||
if 'fd6f' in normalized_uuids:
|
||||
return 'tracker'
|
||||
|
||||
# Service UUIDs (GATT / classic)
|
||||
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
||||
wearable_uuids = {'180d', '1814', '1816'}
|
||||
hid_uuids = {'1812'}
|
||||
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
||||
|
||||
if normalized_uuids & audio_uuids:
|
||||
return 'audio'
|
||||
if normalized_uuids & hid_uuids:
|
||||
return 'peripheral'
|
||||
if normalized_uuids & wearable_uuids:
|
||||
return 'wearable'
|
||||
if normalized_uuids & beacon_uuids:
|
||||
return 'beacon'
|
||||
|
||||
# Check by manufacturer
|
||||
if 'apple' in manufacturer_lower:
|
||||
return 'apple_device'
|
||||
if 'samsung' in manufacturer_lower:
|
||||
return 'samsung_device'
|
||||
|
||||
# Check by class of device
|
||||
if device.major_class:
|
||||
|
||||
284
routes/bt_locate.py
Normal file
284
routes/bt_locate.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
BT Locate — Bluetooth SAR Device Location Flask Blueprint.
|
||||
|
||||
Provides endpoints for managing locate sessions, streaming detection events,
|
||||
and retrieving GPS-tagged signal trails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.bluetooth.irk_extractor import get_paired_irks
|
||||
from utils.bt_locate import (
|
||||
Environment,
|
||||
LocateTarget,
|
||||
get_locate_session,
|
||||
resolve_rpa,
|
||||
start_locate_session,
|
||||
stop_locate_session,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
|
||||
logger = logging.getLogger('intercept.bt_locate')
|
||||
|
||||
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
|
||||
|
||||
|
||||
@bt_locate_bp.route('/start', methods=['POST'])
|
||||
def start_session():
|
||||
"""
|
||||
Start a locate session.
|
||||
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
|
||||
Returns:
|
||||
JSON with session status.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Build target
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
|
||||
# At least one identifier required
|
||||
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]):
|
||||
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
|
||||
|
||||
# Parse environment
|
||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||
|
||||
custom_exponent = data.get('custom_exponent')
|
||||
if custom_exponent is not None:
|
||||
try:
|
||||
custom_exponent = float(custom_exponent)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'custom_exponent must be a number'}), 400
|
||||
|
||||
# Fallback coordinates when GPS is unavailable (from user settings)
|
||||
fallback_lat = None
|
||||
fallback_lon = None
|
||||
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
|
||||
try:
|
||||
fallback_lat = float(data['fallback_lat'])
|
||||
fallback_lon = float(data['fallback_lon'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"Starting locate session: target={target.to_dict()}, "
|
||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||
)
|
||||
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||
def stop_session():
|
||||
"""Stop the active locate session."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'status': 'no_session'})
|
||||
|
||||
stop_locate_session()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
return jsonify(session.get_status())
|
||||
|
||||
|
||||
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||
def get_trail():
|
||||
"""Get detection trail data."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'trail': [], 'gps_trail': []})
|
||||
|
||||
return jsonify({
|
||||
'trail': session.get_trail(),
|
||||
'gps_trail': session.get_gps_trail(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stream', methods=['GET'])
|
||||
def stream_detections():
|
||||
"""SSE stream of detection events."""
|
||||
|
||||
def event_generator() -> Generator[str, None, None]:
|
||||
while True:
|
||||
# Re-fetch session each iteration in case it changes
|
||||
s = get_locate_session()
|
||||
if not s:
|
||||
yield format_sse({'type': 'session_ended'}, event='session_ended')
|
||||
return
|
||||
|
||||
try:
|
||||
event = s.event_queue.get(timeout=2.0)
|
||||
yield format_sse(event, event='detection')
|
||||
except Exception:
|
||||
yield format_sse({}, event='ping')
|
||||
|
||||
return Response(
|
||||
event_generator(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
|
||||
def test_resolve_rpa():
|
||||
"""
|
||||
Test if an IRK resolves to a given address.
|
||||
|
||||
Request JSON:
|
||||
- irk_hex: 16-byte IRK as hex string
|
||||
- address: BLE address string
|
||||
|
||||
Returns:
|
||||
JSON with resolution result.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
irk_hex = data.get('irk_hex', '')
|
||||
address = data.get('address', '')
|
||||
|
||||
if not irk_hex or not address:
|
||||
return jsonify({'error': 'irk_hex and address are required'}), 400
|
||||
|
||||
try:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
||||
|
||||
if len(irk) != 16:
|
||||
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
|
||||
|
||||
result = resolve_rpa(irk, address)
|
||||
return jsonify({
|
||||
'resolved': result,
|
||||
'irk_hex': irk_hex,
|
||||
'address': address,
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/environment', methods=['POST'])
|
||||
def set_environment():
|
||||
"""Update the environment on the active session."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no active session'}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
env_str = data.get('environment', '').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||
|
||||
custom_exponent = data.get('custom_exponent')
|
||||
if custom_exponent is not None:
|
||||
try:
|
||||
custom_exponent = float(custom_exponent)
|
||||
except (ValueError, TypeError):
|
||||
custom_exponent = None
|
||||
|
||||
session.set_environment(environment, custom_exponent)
|
||||
return jsonify({
|
||||
'status': 'updated',
|
||||
'environment': environment.name,
|
||||
'path_loss_exponent': session.estimator.n,
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/debug', methods=['GET'])
|
||||
def debug_matching():
|
||||
"""Debug endpoint showing scanner devices and match results."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no session'})
|
||||
|
||||
scanner = session._scanner
|
||||
if not scanner:
|
||||
return jsonify({'error': 'no scanner'})
|
||||
|
||||
devices = scanner.get_devices(max_age_seconds=30)
|
||||
return jsonify({
|
||||
'target': session.target.to_dict(),
|
||||
'device_count': len(devices),
|
||||
'devices': [
|
||||
{
|
||||
'device_id': d.device_id,
|
||||
'address': d.address,
|
||||
'name': d.name,
|
||||
'rssi': d.rssi_current,
|
||||
'matches': session.target.matches(d),
|
||||
}
|
||||
for d in devices
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/paired_irks', methods=['GET'])
|
||||
def paired_irks():
|
||||
"""Return paired Bluetooth devices that have IRKs."""
|
||||
try:
|
||||
devices = get_paired_irks()
|
||||
except Exception as e:
|
||||
logger.exception("Failed to read paired IRKs")
|
||||
return jsonify({'devices': [], 'error': str(e)})
|
||||
|
||||
return jsonify({'devices': devices})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/clear_trail', methods=['POST'])
|
||||
def clear_trail():
|
||||
"""Clear the detection trail."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'status': 'no_session'})
|
||||
|
||||
session.clear_trail()
|
||||
return jsonify({'status': 'cleared'})
|
||||
@@ -4,19 +4,20 @@ from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.gps import (
|
||||
GPSPosition,
|
||||
GPSSkyData,
|
||||
get_current_position,
|
||||
get_gps_reader,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
GPSPosition,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
|
||||
@@ -29,12 +30,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
def _position_callback(position: GPSPosition) -> None:
|
||||
"""Callback to queue position updates for SSE stream."""
|
||||
try:
|
||||
_gps_queue.put_nowait(position.to_dict())
|
||||
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||
except queue.Full:
|
||||
# Discard oldest if queue is full
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
_gps_queue.put_nowait(position.to_dict())
|
||||
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def _sky_callback(sky: GPSSkyData) -> None:
|
||||
"""Callback to queue sky data updates for SSE stream."""
|
||||
try:
|
||||
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||
except queue.Full:
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
@@ -53,11 +66,13 @@ def auto_connect_gps():
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None
|
||||
'position': position.to_dict() if position else None,
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
})
|
||||
|
||||
# Try to connect to gpsd on localhost:2947
|
||||
@@ -84,14 +99,17 @@ def auto_connect_gps():
|
||||
break
|
||||
|
||||
# Start the gpsd client
|
||||
success = start_gpsd(host, port, callback=_position_callback)
|
||||
success = start_gpsd(host, port,
|
||||
callback=_position_callback,
|
||||
sky_callback=_sky_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': False,
|
||||
'position': None
|
||||
'position': None,
|
||||
'sky': None,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
@@ -106,6 +124,7 @@ def stop_gps_reader():
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
reader.remove_sky_callback(_sky_callback)
|
||||
|
||||
stop_gps()
|
||||
|
||||
@@ -122,15 +141,18 @@ def get_gps_status():
|
||||
'running': False,
|
||||
'device': None,
|
||||
'position': None,
|
||||
'sky': None,
|
||||
'error': None,
|
||||
'message': 'GPS client not started'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'device': reader.device_path,
|
||||
'position': position.to_dict() if position else None,
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
'error': reader.error,
|
||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||
@@ -161,18 +183,42 @@ def get_position():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'sky': sky.to_dict()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'waiting',
|
||||
'message': 'Waiting for satellite data'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position updates."""
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
position = _gps_queue.get(timeout=1)
|
||||
data = _gps_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse({'type': 'position', **position})
|
||||
yield format_sse(data)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
|
||||
@@ -4584,6 +4584,12 @@ header h1 .tagline {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bt-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 4px 0 42px;
|
||||
}
|
||||
|
||||
/* Bluetooth Device Modal */
|
||||
.bt-modal-overlay {
|
||||
position: fixed;
|
||||
|
||||
430
static/css/modes/bt_locate.css
Normal file
430
static/css/modes/bt_locate.css
Normal file
@@ -0,0 +1,430 @@
|
||||
/* BT Locate Mode Styles */
|
||||
|
||||
/* Environment preset grid */
|
||||
.btl-env-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btl-env-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btl-env-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btl-env-btn.active {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: var(--accent-green, #00ff88);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btl-env-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btl-env-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btl-env-n {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY HUD — main visuals area
|
||||
============================================ */
|
||||
|
||||
.btl-hud {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btl-hud-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.btl-hud-band {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 2px;
|
||||
padding: 14px 20px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
min-width: 130px;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-band.immediate {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
|
||||
animation: btl-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btl-hud-band.near {
|
||||
color: #f97316;
|
||||
border-color: #f97316;
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
box-shadow: 0 0 15px rgba(249, 115, 22, 0.15);
|
||||
animation: btl-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btl-hud-band.far {
|
||||
color: #eab308;
|
||||
border-color: #eab308;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
box-shadow: 0 0 10px rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
|
||||
@keyframes btl-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.btl-hud-metrics {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btl-hud-separator {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.btl-hud-metric-lg .btl-hud-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.btl-hud-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.btl-hud-unit {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.btl-hud-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btl-hud-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-audio-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-hud-audio-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btl-hud-clear-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btl-hud-clear-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Bottom info bar */
|
||||
.btl-hud-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btl-hud-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btl-hud-info-item {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btl-hud-info-sep {
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.btl-hud-diag {
|
||||
display: none;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-hud-diag:not(:empty) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUALS AREA — map + chart
|
||||
============================================ */
|
||||
|
||||
.btl-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.btl-map-container {
|
||||
flex: 1;
|
||||
min-height: 250px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#btLocateMap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.btl-rssi-chart-container {
|
||||
height: 100px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-rssi-chart-container .btl-chart-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#btLocateRssiChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOCATE BUTTON — Bluetooth device cards
|
||||
============================================ */
|
||||
|
||||
.bt-locate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent-green, #00ff88);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bt-locate-btn:hover {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
border-color: var(--accent-green, #00ff88);
|
||||
}
|
||||
|
||||
.bt-locate-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
IRK DETECT BUTTON + DEVICE PICKER
|
||||
============================================ */
|
||||
|
||||
.btl-detect-irk-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-detect-irk-btn:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.btl-detect-irk-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.btl-irk-picker {
|
||||
margin-top: 6px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btl-irk-picker-status {
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btl-irk-picker-list {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.btl-irk-picker-item {
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btl-irk-picker-item:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.btl-irk-picker-item:hover {
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
}
|
||||
|
||||
.btl-irk-picker-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btl-irk-picker-meta {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE — stack HUD vertically on narrow
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.btl-hud {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btl-hud-band {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.btl-hud-metrics {
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.btl-hud-controls {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
332
static/css/modes/gps.css
Normal file
332
static/css/modes/gps.css
Normal file
@@ -0,0 +1,332 @@
|
||||
/* GPS Mode Styles */
|
||||
|
||||
/* Sidebar info grid */
|
||||
.gps-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gps-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.gps-info-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-info-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gps-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Connection status */
|
||||
.gps-connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gps-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gps-status-dot.connected {
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-dot.waiting {
|
||||
background: #ffaa00;
|
||||
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Fix badge */
|
||||
.gps-fix-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-fix-badge.no-fix {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: #ff4444;
|
||||
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.gps-fix-badge.fix-2d {
|
||||
background: rgba(255, 170, 0, 0.2);
|
||||
color: #ffaa00;
|
||||
border: 1px solid rgba(255, 170, 0, 0.3);
|
||||
}
|
||||
|
||||
.gps-fix-badge.fix-3d {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* DOP quality indicators */
|
||||
.gps-dop-good { color: #00ff88; }
|
||||
.gps-dop-moderate { color: #ffaa00; }
|
||||
.gps-dop-poor { color: #ff4444; }
|
||||
|
||||
/* ===== Visuals Panel ===== */
|
||||
|
||||
.gps-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Top row: sky view + position info */
|
||||
.gps-visuals-top {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sky View */
|
||||
.gps-skyview-panel {
|
||||
flex: 1;
|
||||
min-width: 320px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.gps-skyview-panel h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Position info panel */
|
||||
.gps-position-panel {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gps-position-panel h4 {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-pos-big {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gps-pos-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gps-pos-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gps-pos-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gps-pos-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Signal Strength Bars */
|
||||
.gps-signal-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.gps-signal-panel h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-signal-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 140px;
|
||||
padding: 0 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.gps-signal-bar-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 18px;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gps-signal-bar {
|
||||
width: 14px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.gps-signal-bar.unused {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.gps-signal-prn {
|
||||
font-size: 8px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
.gps-signal-snr {
|
||||
font-size: 7px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Constellation colors */
|
||||
.gps-const-gps { background-color: #00d4ff; }
|
||||
.gps-const-glonass { background-color: #00ff88; }
|
||||
.gps-const-galileo { background-color: #ff8800; }
|
||||
.gps-const-beidou { background-color: #ff4466; }
|
||||
.gps-const-sbas { background-color: #ffdd00; }
|
||||
.gps-const-qzss { background-color: #cc66ff; }
|
||||
|
||||
/* Legend */
|
||||
.gps-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gps-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.gps-legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.gps-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gps-empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.gps-empty-state p {
|
||||
font-size: 12px;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.gps-visuals-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gps-skyview-panel,
|
||||
.gps-position-panel {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.gps-pos-big {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -366,10 +366,10 @@ const BluetoothMode = (function() {
|
||||
// Badges
|
||||
const badgesEl = document.getElementById('btDetailBadges');
|
||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||
if (device.seen_before) {
|
||||
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||
}
|
||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||
if (device.seen_before) {
|
||||
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||
}
|
||||
|
||||
// Tracker badge
|
||||
if (device.is_tracker) {
|
||||
@@ -451,14 +451,14 @@ const BluetoothMode = (function() {
|
||||
? minMax[0] + '/' + minMax[1]
|
||||
: '--';
|
||||
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
|
||||
updateWatchlistButton(device);
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
|
||||
updateWatchlistButton(device);
|
||||
|
||||
// Services
|
||||
const servicesContainer = document.getElementById('btDetailServices');
|
||||
@@ -470,29 +470,29 @@ const BluetoothMode = (function() {
|
||||
servicesContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
|
||||
// Highlight selected device in list
|
||||
highlightSelectedDevice(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist button state
|
||||
*/
|
||||
function updateWatchlistButton(device) {
|
||||
const btn = document.getElementById('btDetailWatchBtn');
|
||||
if (!btn) return;
|
||||
if (typeof AlertCenter === 'undefined') {
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
btn.style.display = '';
|
||||
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||
btn.classList.toggle('active', watchlisted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watchlist button state
|
||||
*/
|
||||
function updateWatchlistButton(device) {
|
||||
const btn = document.getElementById('btDetailWatchBtn');
|
||||
if (!btn) return;
|
||||
if (typeof AlertCenter === 'undefined') {
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
btn.style.display = '';
|
||||
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||
btn.classList.toggle('active', watchlisted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device selection
|
||||
@@ -546,43 +546,43 @@ const BluetoothMode = (function() {
|
||||
/**
|
||||
* Copy selected device address to clipboard
|
||||
*/
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.getElementById('btDetailCopyBtn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.getElementById('btDetailCopyBtn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.background = '';
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Bluetooth watchlist for selected device
|
||||
*/
|
||||
function toggleWatchlist() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device || typeof AlertCenter === 'undefined') return;
|
||||
|
||||
if (AlertCenter.isWatchlisted(device.address)) {
|
||||
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||
showInfo('Removed from watchlist');
|
||||
} else {
|
||||
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||
showInfo('Added to watchlist');
|
||||
}
|
||||
|
||||
setTimeout(() => updateWatchlistButton(device), 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Bluetooth watchlist for selected device
|
||||
*/
|
||||
function toggleWatchlist() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device || typeof AlertCenter === 'undefined') return;
|
||||
|
||||
if (AlertCenter.isWatchlisted(device.address)) {
|
||||
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||
showInfo('Removed from watchlist');
|
||||
} else {
|
||||
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||
showInfo('Added to watchlist');
|
||||
}
|
||||
|
||||
setTimeout(() => updateWatchlistButton(device), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a device - opens modal with details
|
||||
@@ -1130,11 +1130,11 @@ const BluetoothMode = (function() {
|
||||
const isNew = !inBaseline;
|
||||
const hasName = !!device.name;
|
||||
const isTracker = device.is_tracker === true;
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const seenBefore = device.seen_before === true;
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const seenBefore = device.seen_before === true;
|
||||
|
||||
// Calculate RSSI bar width (0-100%)
|
||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||
@@ -1186,9 +1186,9 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
// Add agent name if not Local
|
||||
if (agentName !== 'Local') {
|
||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||
@@ -1216,6 +1216,11 @@ const BluetoothMode = (function() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
||||
'<div class="bt-row-actions">' +
|
||||
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>' +
|
||||
'Locate</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -1391,6 +1396,42 @@ const BluetoothMode = (function() {
|
||||
updateRadar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand off a device to BT Locate mode by device_id lookup.
|
||||
*/
|
||||
function locateById(deviceId) {
|
||||
console.log('[BT] locateById called with:', deviceId);
|
||||
const device = devices.get(deviceId);
|
||||
if (!device) {
|
||||
console.warn('[BT] Device not found in map for id:', deviceId);
|
||||
return;
|
||||
}
|
||||
doLocateHandoff(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand off the currently selected device to BT Locate mode.
|
||||
*/
|
||||
function locateDevice() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
doLocateHandoff(device);
|
||||
}
|
||||
|
||||
function doLocateHandoff(device) {
|
||||
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
||||
if (typeof BtLocate !== 'undefined') {
|
||||
BtLocate.handoff({
|
||||
device_id: device.device_id,
|
||||
mac_address: device.address,
|
||||
known_name: device.name || null,
|
||||
known_manufacturer: device.manufacturer_name || null,
|
||||
last_known_rssi: device.rssi_current
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
@@ -1400,10 +1441,12 @@ const BluetoothMode = (function() {
|
||||
setBaseline,
|
||||
clearBaseline,
|
||||
exportData,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
toggleWatchlist,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
toggleWatchlist,
|
||||
locateDevice,
|
||||
locateById,
|
||||
|
||||
// Agent handling
|
||||
handleAgentChange,
|
||||
|
||||
732
static/js/modes/bt_locate.js
Normal file
732
static/js/modes/bt_locate.js
Normal file
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* BT Locate — Bluetooth SAR Device Location Mode
|
||||
* GPS-tagged signal trail mapping with proximity audio alerts.
|
||||
*/
|
||||
const BtLocate = (function() {
|
||||
'use strict';
|
||||
|
||||
let eventSource = null;
|
||||
let map = null;
|
||||
let mapMarkers = [];
|
||||
let trailLine = null;
|
||||
let rssiHistory = [];
|
||||
const MAX_RSSI_POINTS = 60;
|
||||
let chartCanvas = null;
|
||||
let chartCtx = null;
|
||||
let currentEnvironment = 'OUTDOOR';
|
||||
let audioCtx = null;
|
||||
let audioEnabled = false;
|
||||
let beepTimer = null;
|
||||
let initialized = false;
|
||||
let handoffData = null;
|
||||
let pollTimer = null;
|
||||
let durationTimer = null;
|
||||
let sessionStartedAt = null;
|
||||
let lastDetectionCount = 0;
|
||||
|
||||
function init() {
|
||||
if (initialized) {
|
||||
// Re-invalidate map on re-entry and ensure tiles are present
|
||||
if (map) {
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
// Re-apply user's tile layer if tiles were lost
|
||||
let hasTiles = false;
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.TileLayer) hasTiles = true;
|
||||
});
|
||||
if (!hasTiles && typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
Settings.createTileLayer().addTo(map);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
checkStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Init map
|
||||
const mapEl = document.getElementById('btLocateMap');
|
||||
if (mapEl && typeof L !== 'undefined') {
|
||||
map = L.map('btLocateMap', {
|
||||
center: [0, 0],
|
||||
zoom: 2,
|
||||
zoomControl: true,
|
||||
});
|
||||
// Use tile provider from user settings
|
||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
Settings.createTileLayer().addTo(map);
|
||||
Settings.registerMap(map);
|
||||
} else {
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OSM © CARTO'
|
||||
}).addTo(map);
|
||||
}
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
}
|
||||
|
||||
// Init RSSI chart canvas
|
||||
chartCanvas = document.getElementById('btLocateRssiChart');
|
||||
if (chartCanvas) {
|
||||
chartCtx = chartCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
checkStatus();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.active) {
|
||||
sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
updateScanStatus(data);
|
||||
if (!eventSource) connectSSE();
|
||||
// Restore trail from server
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.gps_trail) {
|
||||
trail.gps_trail.forEach(p => addMapMarker(p));
|
||||
}
|
||||
updateStats(data.detection_count, data.gps_trail_count);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function start() {
|
||||
const mac = document.getElementById('btLocateMac')?.value.trim();
|
||||
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
|
||||
const irk = document.getElementById('btLocateIrk')?.value.trim();
|
||||
|
||||
const body = { environment: currentEnvironment };
|
||||
if (mac) body.mac_address = mac;
|
||||
if (namePattern) body.name_pattern = namePattern;
|
||||
if (irk) body.irk_hex = irk;
|
||||
if (handoffData?.device_id) body.device_id = handoffData.device_id;
|
||||
if (handoffData?.known_name) body.known_name = handoffData.known_name;
|
||||
if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer;
|
||||
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
|
||||
|
||||
// Include user location as fallback when GPS unavailable
|
||||
const userLat = localStorage.getItem('observerLat');
|
||||
const userLon = localStorage.getItem('observerLon');
|
||||
if (userLat && userLon) {
|
||||
body.fallback_lat = parseFloat(userLat);
|
||||
body.fallback_lon = parseFloat(userLon);
|
||||
}
|
||||
|
||||
console.log('[BtLocate] Starting with body:', body);
|
||||
|
||||
if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id) {
|
||||
alert('Please provide at least a MAC address, name pattern, IRK, or use hand-off from Bluetooth mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/bt_locate/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
connectSSE();
|
||||
rssiHistory = [];
|
||||
updateScanStatus(data.session);
|
||||
// Restore any existing trail (e.g. from a stop/start cycle)
|
||||
restoreTrail();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Start error:', err));
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/bt_locate/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
stopAudio();
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||
}
|
||||
|
||||
function showActiveUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = 'inline-block';
|
||||
show('btLocateHud');
|
||||
}
|
||||
|
||||
function showIdleUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'inline-block';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
hide('btLocateHud');
|
||||
hide('btLocateScanStatus');
|
||||
}
|
||||
|
||||
function updateScanStatus(statusData) {
|
||||
const el = document.getElementById('btLocateScanStatus');
|
||||
const dot = document.getElementById('btLocateScanDot');
|
||||
const text = document.getElementById('btLocateScanText');
|
||||
if (!el) return;
|
||||
|
||||
el.style.display = '';
|
||||
if (statusData && statusData.scanner_running) {
|
||||
if (dot) dot.style.background = '#22c55e';
|
||||
if (text) text.textContent = 'BT scanner active';
|
||||
} else {
|
||||
if (dot) dot.style.background = '#f97316';
|
||||
if (text) text.textContent = 'BT scanner not running — waiting...';
|
||||
}
|
||||
}
|
||||
|
||||
function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
|
||||
function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
console.log('[BtLocate] Connecting SSE stream');
|
||||
eventSource = new EventSource('/bt_locate/stream');
|
||||
|
||||
eventSource.addEventListener('detection', function(e) {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
console.log('[BtLocate] Detection event:', event);
|
||||
handleDetection(event);
|
||||
} catch (err) {
|
||||
console.error('[BtLocate] Parse error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('session_ended', function() {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
});
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[BtLocate] SSE error, polling fallback active');
|
||||
};
|
||||
|
||||
// Start polling fallback (catches data even if SSE fails)
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
lastDetectionCount = 0;
|
||||
pollTimer = setInterval(pollStatus, 3000);
|
||||
startDurationTimer();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
stopDurationTimer();
|
||||
}
|
||||
|
||||
function startDurationTimer() {
|
||||
stopDurationTimer();
|
||||
durationTimer = setInterval(updateDuration, 1000);
|
||||
}
|
||||
|
||||
function stopDurationTimer() {
|
||||
if (durationTimer) {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDuration() {
|
||||
if (!sessionStartedAt) return;
|
||||
const elapsed = Math.round((Date.now() - sessionStartedAt) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeEl = document.getElementById('btLocateSessionTime');
|
||||
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.active) {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
|
||||
updateScanStatus(data);
|
||||
updateHudInfo(data);
|
||||
|
||||
// Show diagnostics
|
||||
const diagEl = document.getElementById('btLocateDiag');
|
||||
if (diagEl) {
|
||||
let diag = 'Polls: ' + (data.poll_count || 0) +
|
||||
(data.poll_thread_alive === false ? ' DEAD' : '') +
|
||||
' | Scan: ' + (data.scanner_running ? 'Y' : 'N') +
|
||||
' | Devices: ' + (data.scanner_device_count || 0) +
|
||||
' | Det: ' + (data.detection_count || 0);
|
||||
// Show debug device sample if no detections
|
||||
if (data.detection_count === 0 && data.debug_devices && data.debug_devices.length > 0) {
|
||||
const matched = data.debug_devices.filter(d => d.match);
|
||||
const sample = data.debug_devices.slice(0, 3).map(d =>
|
||||
(d.name || '?') + '|' + (d.id || '').substring(0, 12) + ':' + (d.match ? 'Y' : 'N')
|
||||
).join(', ');
|
||||
diag += ' | Match:' + matched.length + '/' + data.debug_devices.length + ' [' + sample + ']';
|
||||
}
|
||||
diagEl.textContent = diag;
|
||||
}
|
||||
|
||||
// If detection count increased, fetch new trail points
|
||||
if (data.detection_count > lastDetectionCount) {
|
||||
lastDetectionCount = data.detection_count;
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.trail && trail.trail.length > 0) {
|
||||
const latest = trail.trail[trail.trail.length - 1];
|
||||
handleDetection({ data: latest });
|
||||
}
|
||||
updateStats(data.detection_count, data.gps_trail_count);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateHudInfo(data) {
|
||||
// Target info
|
||||
const targetEl = document.getElementById('btLocateTargetInfo');
|
||||
if (targetEl && data.target) {
|
||||
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 || '--';
|
||||
}
|
||||
|
||||
// Environment info
|
||||
const envEl = document.getElementById('btLocateEnvInfo');
|
||||
if (envEl) {
|
||||
const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' };
|
||||
envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?');
|
||||
}
|
||||
|
||||
// GPS status
|
||||
const gpsEl = document.getElementById('btLocateGpsStatus');
|
||||
if (gpsEl) {
|
||||
const src = data.gps_source || 'none';
|
||||
if (src === 'live') gpsEl.textContent = 'GPS: Live';
|
||||
else if (src === 'manual') gpsEl.textContent = 'GPS: Manual';
|
||||
else gpsEl.textContent = 'GPS: None';
|
||||
}
|
||||
|
||||
// Last seen
|
||||
const lastEl = document.getElementById('btLocateLastSeen');
|
||||
if (lastEl) {
|
||||
if (data.last_detection) {
|
||||
const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000);
|
||||
lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago');
|
||||
} else {
|
||||
lastEl.textContent = 'Last: --';
|
||||
}
|
||||
}
|
||||
|
||||
// Session start time (duration handled by 1s timer)
|
||||
if (data.started_at && !sessionStartedAt) {
|
||||
sessionStartedAt = new Date(data.started_at).getTime();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetection(event) {
|
||||
const d = event.data;
|
||||
if (!d) return;
|
||||
|
||||
// Update proximity UI
|
||||
const bandEl = document.getElementById('btLocateBand');
|
||||
const distEl = document.getElementById('btLocateDistance');
|
||||
const rssiEl = document.getElementById('btLocateRssi');
|
||||
const rssiEmaEl = document.getElementById('btLocateRssiEma');
|
||||
|
||||
if (bandEl) {
|
||||
bandEl.textContent = d.proximity_band;
|
||||
bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase();
|
||||
}
|
||||
if (distEl) distEl.textContent = d.estimated_distance.toFixed(1);
|
||||
if (rssiEl) rssiEl.textContent = d.rssi;
|
||||
if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
|
||||
|
||||
// RSSI sparkline
|
||||
rssiHistory.push(d.rssi);
|
||||
if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
|
||||
drawRssiChart();
|
||||
|
||||
// Map marker
|
||||
if (d.lat != null && d.lon != null) {
|
||||
addMapMarker(d);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||
if (detCountEl) {
|
||||
const cur = parseInt(detCountEl.textContent) || 0;
|
||||
detCountEl.textContent = cur + 1;
|
||||
}
|
||||
if (gpsCountEl && d.lat != null) {
|
||||
const cur = parseInt(gpsCountEl.textContent) || 0;
|
||||
gpsCountEl.textContent = cur + 1;
|
||||
}
|
||||
|
||||
// Audio
|
||||
if (audioEnabled) playProximityTone(d.rssi);
|
||||
}
|
||||
|
||||
function updateStats(detections, gpsPoints) {
|
||||
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||
if (detCountEl) detCountEl.textContent = detections || 0;
|
||||
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
||||
}
|
||||
|
||||
function addMapMarker(point) {
|
||||
if (!map || point.lat == null || point.lon == null) return;
|
||||
|
||||
const band = (point.proximity_band || 'FAR').toLowerCase();
|
||||
const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
|
||||
const sizes = { immediate: 8, near: 6, far: 5 };
|
||||
const color = colors[band] || '#eab308';
|
||||
const radius = sizes[band] || 5;
|
||||
|
||||
const marker = L.circleMarker([point.lat, point.lon], {
|
||||
radius: radius,
|
||||
fillColor: color,
|
||||
color: '#fff',
|
||||
weight: 1,
|
||||
opacity: 0.9,
|
||||
fillOpacity: 0.8,
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(
|
||||
'<div style="font-family:monospace;font-size:11px;">' +
|
||||
'<b>' + point.proximity_band + '</b><br>' +
|
||||
'RSSI: ' + point.rssi + ' dBm<br>' +
|
||||
'Distance: ~' + point.estimated_distance.toFixed(1) + ' m<br>' +
|
||||
'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
mapMarkers.push(marker);
|
||||
map.panTo([point.lat, point.lon]);
|
||||
|
||||
// Update trail line
|
||||
const latlngs = mapMarkers.map(m => m.getLatLng());
|
||||
if (trailLine) {
|
||||
trailLine.setLatLngs(latlngs);
|
||||
} else if (latlngs.length >= 2) {
|
||||
trailLine = L.polyline(latlngs, {
|
||||
color: 'rgba(0,255,136,0.5)',
|
||||
weight: 2,
|
||||
dashArray: '4 4',
|
||||
}).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTrail() {
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.gps_trail && trail.gps_trail.length > 0) {
|
||||
clearMapMarkers();
|
||||
trail.gps_trail.forEach(p => addMapMarker(p));
|
||||
}
|
||||
if (trail.trail && trail.trail.length > 0) {
|
||||
// Restore RSSI history from trail
|
||||
rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
|
||||
drawRssiChart();
|
||||
// Update HUD with latest detection
|
||||
const latest = trail.trail[trail.trail.length - 1];
|
||||
handleDetection({ data: latest });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function clearMapMarkers() {
|
||||
mapMarkers.forEach(m => map?.removeLayer(m));
|
||||
mapMarkers = [];
|
||||
if (trailLine) {
|
||||
map?.removeLayer(trailLine);
|
||||
trailLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRssiChart() {
|
||||
if (!chartCtx || !chartCanvas) return;
|
||||
|
||||
const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16;
|
||||
const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24;
|
||||
chartCtx.clearRect(0, 0, w, h);
|
||||
|
||||
if (rssiHistory.length < 2) return;
|
||||
|
||||
// RSSI range: -100 to -20
|
||||
const minR = -100, maxR = -20;
|
||||
const range = maxR - minR;
|
||||
|
||||
// Grid lines
|
||||
chartCtx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
chartCtx.lineWidth = 1;
|
||||
[-30, -50, -70, -90].forEach(v => {
|
||||
const y = h - ((v - minR) / range) * h;
|
||||
chartCtx.beginPath();
|
||||
chartCtx.moveTo(0, y);
|
||||
chartCtx.lineTo(w, y);
|
||||
chartCtx.stroke();
|
||||
});
|
||||
|
||||
// Draw RSSI line
|
||||
const step = w / (MAX_RSSI_POINTS - 1);
|
||||
chartCtx.beginPath();
|
||||
chartCtx.strokeStyle = '#00ff88';
|
||||
chartCtx.lineWidth = 2;
|
||||
|
||||
rssiHistory.forEach((rssi, i) => {
|
||||
const x = i * step;
|
||||
const y = h - ((rssi - minR) / range) * h;
|
||||
if (i === 0) chartCtx.moveTo(x, y);
|
||||
else chartCtx.lineTo(x, y);
|
||||
});
|
||||
chartCtx.stroke();
|
||||
|
||||
// Fill under
|
||||
const lastIdx = rssiHistory.length - 1;
|
||||
chartCtx.lineTo(lastIdx * step, h);
|
||||
chartCtx.lineTo(0, h);
|
||||
chartCtx.closePath();
|
||||
chartCtx.fillStyle = 'rgba(0,255,136,0.08)';
|
||||
chartCtx.fill();
|
||||
}
|
||||
|
||||
// Audio proximity tone (Web Audio API)
|
||||
function playTone(freq, duration) {
|
||||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.2;
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + duration);
|
||||
}
|
||||
|
||||
function playProximityTone(rssi) {
|
||||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||
// Stronger signal = higher pitch and shorter beep
|
||||
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
|
||||
const freq = 400 + strength * 800; // 400-1200 Hz
|
||||
const duration = 0.06 + (1 - strength) * 0.12;
|
||||
playTone(freq, duration);
|
||||
}
|
||||
|
||||
function toggleAudio() {
|
||||
const cb = document.getElementById('btLocateAudioEnable');
|
||||
audioEnabled = cb?.checked || false;
|
||||
if (audioEnabled) {
|
||||
// Create AudioContext on user gesture (required by browser policy)
|
||||
if (!audioCtx) {
|
||||
try {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} catch (e) {
|
||||
console.error('[BtLocate] AudioContext creation failed:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Resume must happen within a user gesture handler
|
||||
const ctx = audioCtx;
|
||||
ctx.resume().then(() => {
|
||||
console.log('[BtLocate] AudioContext state:', ctx.state);
|
||||
// Confirmation beep so user knows audio is working
|
||||
playTone(600, 0.08);
|
||||
});
|
||||
} else {
|
||||
stopAudio();
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
audioEnabled = false;
|
||||
const cb = document.getElementById('btLocateAudioEnable');
|
||||
if (cb) cb.checked = false;
|
||||
}
|
||||
|
||||
function setEnvironment(env) {
|
||||
currentEnvironment = env;
|
||||
document.querySelectorAll('.btl-env-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.env === env);
|
||||
});
|
||||
// Push to running session if active
|
||||
fetch('/bt_locate/status').then(r => r.json()).then(data => {
|
||||
if (data.active) {
|
||||
fetch('/bt_locate/environment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ environment: env }),
|
||||
}).then(r => r.json()).then(res => {
|
||||
console.log('[BtLocate] Environment updated:', res);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function handoff(deviceInfo) {
|
||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||
handoffData = deviceInfo;
|
||||
|
||||
// Populate fields
|
||||
if (deviceInfo.mac_address) {
|
||||
const macInput = document.getElementById('btLocateMac');
|
||||
if (macInput) macInput.value = deviceInfo.mac_address;
|
||||
}
|
||||
|
||||
// Show handoff card
|
||||
const card = document.getElementById('btLocateHandoffCard');
|
||||
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 (metaEl) {
|
||||
const parts = [];
|
||||
if (deviceInfo.mac_address) parts.push(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 ');
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate');
|
||||
}
|
||||
}
|
||||
|
||||
function clearHandoff() {
|
||||
handoffData = null;
|
||||
const card = document.getElementById('btLocateHandoffCard');
|
||||
if (card) card.style.display = 'none';
|
||||
}
|
||||
|
||||
function fetchPairedIrks() {
|
||||
const picker = document.getElementById('btLocateIrkPicker');
|
||||
const status = document.getElementById('btLocateIrkPickerStatus');
|
||||
const list = document.getElementById('btLocateIrkPickerList');
|
||||
const btn = document.getElementById('btLocateDetectIrkBtn');
|
||||
if (!picker || !status || !list) return;
|
||||
|
||||
// Toggle off if already visible
|
||||
if (picker.style.display !== 'none') {
|
||||
picker.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
picker.style.display = '';
|
||||
list.innerHTML = '';
|
||||
status.textContent = 'Scanning paired devices...';
|
||||
status.style.display = '';
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
fetch('/bt_locate/paired_irks')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (btn) btn.disabled = false;
|
||||
const devices = data.devices || [];
|
||||
|
||||
if (devices.length === 0) {
|
||||
status.textContent = 'No paired devices with IRKs found';
|
||||
return;
|
||||
}
|
||||
|
||||
status.style.display = 'none';
|
||||
list.innerHTML = '';
|
||||
|
||||
devices.forEach(dev => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'btl-irk-picker-item';
|
||||
item.innerHTML =
|
||||
'<div class="btl-irk-picker-name">' + (dev.name || 'Unknown Device') + '</div>' +
|
||||
'<div class="btl-irk-picker-meta">' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '</div>';
|
||||
item.addEventListener('click', function() {
|
||||
selectPairedIrk(dev);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
if (btn) btn.disabled = false;
|
||||
console.error('[BtLocate] Failed to fetch paired IRKs:', err);
|
||||
status.textContent = 'Failed to read paired devices';
|
||||
});
|
||||
}
|
||||
|
||||
function selectPairedIrk(dev) {
|
||||
const irkInput = document.getElementById('btLocateIrk');
|
||||
const nameInput = document.getElementById('btLocateNamePattern');
|
||||
const picker = document.getElementById('btLocateIrkPicker');
|
||||
|
||||
if (irkInput) irkInput.value = dev.irk_hex;
|
||||
if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name;
|
||||
if (picker) picker.style.display = 'none';
|
||||
}
|
||||
|
||||
function clearTrail() {
|
||||
fetch('/bt_locate/clear_trail', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
clearMapMarkers();
|
||||
rssiHistory = [];
|
||||
drawRssiChart();
|
||||
updateStats(0, 0);
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Clear trail error:', err));
|
||||
}
|
||||
|
||||
function invalidateMap() {
|
||||
if (map) map.invalidateSize();
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
handoff,
|
||||
clearHandoff,
|
||||
setEnvironment,
|
||||
toggleAudio,
|
||||
clearTrail,
|
||||
handleDetection,
|
||||
invalidateMap,
|
||||
fetchPairedIrks,
|
||||
};
|
||||
})();
|
||||
401
static/js/modes/gps.js
Normal file
401
static/js/modes/gps.js
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* GPS Mode
|
||||
* Live GPS data display with satellite sky view, signal strength bars,
|
||||
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
|
||||
*/
|
||||
|
||||
const GPS = (function() {
|
||||
let eventSource = null;
|
||||
let connected = false;
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
'GPS': '#00d4ff',
|
||||
'GLONASS': '#00ff88',
|
||||
'Galileo': '#ff8800',
|
||||
'BeiDou': '#ff4466',
|
||||
'SBAS': '#ffdd00',
|
||||
'QZSS': '#cc66ff',
|
||||
};
|
||||
|
||||
function init() {
|
||||
drawEmptySkyView();
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
fetch('/gps/auto-connect', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'connected') {
|
||||
connected = true;
|
||||
updateConnectionUI(true, data.has_fix);
|
||||
if (data.position) {
|
||||
lastPosition = data.position;
|
||||
updatePositionUI(data.position);
|
||||
}
|
||||
if (data.sky) {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
startStream();
|
||||
} else {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
fetch('/gps/stop', { method: 'POST' })
|
||||
.then(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
});
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
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) {
|
||||
const dot = document.getElementById('gpsStatusDot');
|
||||
const text = document.getElementById('gpsStatusText');
|
||||
const connectBtn = document.getElementById('gpsConnectBtn');
|
||||
const disconnectBtn = document.getElementById('gpsDisconnectBtn');
|
||||
const devicePath = document.getElementById('gpsDevicePath');
|
||||
|
||||
if (dot) {
|
||||
dot.className = 'gps-status-dot';
|
||||
if (isConnected && hasFix) dot.classList.add('connected');
|
||||
else if (isConnected) dot.classList.add('waiting');
|
||||
}
|
||||
if (text) {
|
||||
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 (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
||||
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
||||
}
|
||||
|
||||
function updatePositionUI(pos) {
|
||||
// Sidebar fields
|
||||
setText('gpsLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||
setText('gpsSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||
setText('gpsHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||
setText('gpsClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||
|
||||
// Fix type
|
||||
const fixEl = document.getElementById('gpsFixType');
|
||||
if (fixEl) {
|
||||
const fq = pos.fix_quality;
|
||||
if (fq === 3) fixEl.innerHTML = '<span class="gps-fix-badge fix-3d">3D FIX</span>';
|
||||
else if (fq === 2) fixEl.innerHTML = '<span class="gps-fix-badge fix-2d">2D FIX</span>';
|
||||
else fixEl.innerHTML = '<span class="gps-fix-badge no-fix">NO FIX</span>';
|
||||
}
|
||||
|
||||
// Error estimates
|
||||
const eph = (pos.epx != null && pos.epy != null) ? Math.sqrt(pos.epx * pos.epx + pos.epy * pos.epy) : null;
|
||||
setText('gpsEph', eph != null ? eph.toFixed(1) + ' m' : '---');
|
||||
setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---');
|
||||
setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---');
|
||||
|
||||
// GPS time
|
||||
if (pos.timestamp) {
|
||||
const t = new Date(pos.timestamp);
|
||||
setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||
}
|
||||
|
||||
// Visuals: position panel
|
||||
setText('gpsVisPosLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsVisPosLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||
setText('gpsVisPosAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||
setText('gpsVisPosSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||
setText('gpsVisPosHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||
setText('gpsVisPosClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||
|
||||
// Visuals: fix badge
|
||||
const visFixEl = document.getElementById('gpsVisFixBadge');
|
||||
if (visFixEl) {
|
||||
const fq = pos.fix_quality;
|
||||
if (fq === 3) { visFixEl.textContent = '3D FIX'; visFixEl.className = 'gps-fix-badge fix-3d'; }
|
||||
else if (fq === 2) { visFixEl.textContent = '2D FIX'; visFixEl.className = 'gps-fix-badge fix-2d'; }
|
||||
else { visFixEl.textContent = 'NO FIX'; visFixEl.className = 'gps-fix-badge no-fix'; }
|
||||
}
|
||||
|
||||
// Visuals: GPS time
|
||||
if (pos.timestamp) {
|
||||
const t = new Date(pos.timestamp);
|
||||
setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateSkyUI(sky) {
|
||||
// Sidebar sat counts
|
||||
setText('gpsSatUsed', sky.usat != null ? sky.usat : '-');
|
||||
setText('gpsSatTotal', sky.nsat != null ? sky.nsat : '-');
|
||||
|
||||
// DOP values
|
||||
setDop('gpsHdop', sky.hdop);
|
||||
setDop('gpsVdop', sky.vdop);
|
||||
setDop('gpsPdop', sky.pdop);
|
||||
setDop('gpsTdop', sky.tdop);
|
||||
setDop('gpsGdop', sky.gdop);
|
||||
|
||||
// Visuals
|
||||
drawSkyView(sky.satellites || []);
|
||||
drawSignalBars(sky.satellites || []);
|
||||
}
|
||||
|
||||
function setDop(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (val == null) { el.textContent = '---'; el.className = 'gps-info-value gps-mono'; return; }
|
||||
el.textContent = val.toFixed(1);
|
||||
let cls = 'gps-info-value gps-mono ';
|
||||
if (val <= 2) cls += 'gps-dop-good';
|
||||
else if (val <= 5) cls += 'gps-dop-moderate';
|
||||
else cls += 'gps-dop-poor';
|
||||
el.className = cls;
|
||||
}
|
||||
|
||||
function setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Sky View Polar Plot
|
||||
// ========================
|
||||
|
||||
function drawEmptySkyView() {
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
drawSkyViewBase(canvas);
|
||||
}
|
||||
|
||||
function drawSkyView(satellites) {
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
drawSkyViewBase(canvas);
|
||||
|
||||
// Plot satellites
|
||||
satellites.forEach(sat => {
|
||||
if (sat.elevation == null || sat.azimuth == null) return;
|
||||
|
||||
const elRad = (90 - sat.elevation) / 90;
|
||||
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
||||
const px = cx + r * elRad * Math.cos(azRad);
|
||||
const py = cy + r * elRad * Math.sin(azRad);
|
||||
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||
const dotSize = sat.used ? 6 : 4;
|
||||
|
||||
// Draw dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||
if (sat.used) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// PRN label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '8px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||
|
||||
// SNR value
|
||||
if (sat.snr != null) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '7px JetBrains Mono, monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawSkyViewBase(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const r = Math.min(cx, cy) - 24;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background
|
||||
const bgStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-card').trim();
|
||||
ctx.fillStyle = bgStyle || '#0d1117';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Elevation rings (0, 30, 60, 90)
|
||||
ctx.strokeStyle = '#2a3040';
|
||||
ctx.lineWidth = 0.5;
|
||||
[90, 60, 30].forEach(el => {
|
||||
const gr = r * (1 - el / 90);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Label
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '9px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||
});
|
||||
|
||||
// Horizon circle
|
||||
ctx.strokeStyle = '#3a4050';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Cardinal directions
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = 'bold 11px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('N', cx, cy - r - 12);
|
||||
ctx.fillText('S', cx, cy + r + 12);
|
||||
ctx.fillText('E', cx + r + 12, cy);
|
||||
ctx.fillText('W', cx - r - 12, cy);
|
||||
|
||||
// Crosshairs
|
||||
ctx.strokeStyle = '#2a3040';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - r);
|
||||
ctx.lineTo(cx, cy + r);
|
||||
ctx.moveTo(cx - r, cy);
|
||||
ctx.lineTo(cx + r, cy);
|
||||
ctx.stroke();
|
||||
|
||||
// Zenith dot
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Signal Strength Bars
|
||||
// ========================
|
||||
|
||||
function drawSignalBars(satellites) {
|
||||
const container = document.getElementById('gpsSignalBars');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (satellites.length === 0) return;
|
||||
|
||||
// Sort: used first, then by PRN
|
||||
const sorted = [...satellites].sort((a, b) => {
|
||||
if (a.used !== b.used) return a.used ? -1 : 1;
|
||||
return a.prn - b.prn;
|
||||
});
|
||||
|
||||
const maxSnr = 50; // dB-Hz typical max for display
|
||||
|
||||
sorted.forEach(sat => {
|
||||
const snr = sat.snr || 0;
|
||||
const heightPct = Math.min(snr / maxSnr * 100, 100);
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||
const constClass = 'gps-const-' + (sat.constellation || 'GPS').toLowerCase();
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'gps-signal-bar-wrap';
|
||||
|
||||
const snrLabel = document.createElement('span');
|
||||
snrLabel.className = 'gps-signal-snr';
|
||||
snrLabel.textContent = snr > 0 ? Math.round(snr) : '';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'gps-signal-bar ' + constClass + (sat.used ? '' : ' unused');
|
||||
bar.style.height = Math.max(heightPct, 2) + '%';
|
||||
bar.title = `PRN ${sat.prn} (${sat.constellation}) - ${Math.round(snr)} dB-Hz${sat.used ? ' [USED]' : ''}`;
|
||||
|
||||
const prn = document.createElement('span');
|
||||
prn.className = 'gps-signal-prn';
|
||||
prn.textContent = sat.prn;
|
||||
|
||||
wrap.appendChild(snrLabel);
|
||||
wrap.appendChild(bar);
|
||||
wrap.appendChild(prn);
|
||||
container.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Cleanup
|
||||
// ========================
|
||||
|
||||
function destroy() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
destroy: destroy,
|
||||
};
|
||||
})();
|
||||
@@ -63,7 +63,9 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||
@@ -248,6 +250,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"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||||
<span class="mode-name">HF SSTV</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('gps')">
|
||||
<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"/></svg></span>
|
||||
<span class="mode-name">GPS</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,6 +544,8 @@
|
||||
|
||||
{% include 'partials/modes/sstv-general.html' %}
|
||||
|
||||
{% include 'partials/modes/gps.html' %}
|
||||
|
||||
{% include 'partials/modes/listening-post.html' %}
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
@@ -554,6 +562,8 @@
|
||||
|
||||
{% include 'partials/modes/subghz.html' %}
|
||||
|
||||
{% include 'partials/modes/bt_locate.html' %}
|
||||
|
||||
<button class="preset-btn" onclick="killAll()"
|
||||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||||
Kill All Processes
|
||||
@@ -828,6 +838,7 @@
|
||||
</div>
|
||||
<button class="bt-detail-btn" id="btDetailWatchBtn" onclick="BluetoothMode.toggleWatchlist()">Watchlist</button>
|
||||
<button class="bt-detail-btn" id="btDetailCopyBtn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
||||
<button class="bt-detail-btn bt-locate-btn" id="btDetailLocateBtn" onclick="BluetoothMode.locateDevice()">Locate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1043,6 +1054,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GPS Receiver Dashboard -->
|
||||
<div id="gpsVisuals" class="gps-visuals-container" style="display: none;">
|
||||
<div class="gps-visuals-top">
|
||||
<!-- Sky View Polar Plot -->
|
||||
<div class="gps-skyview-panel">
|
||||
<h4>Satellite Sky View</h4>
|
||||
<div class="gps-skyview-canvas-wrap">
|
||||
<canvas id="gpsSkyCanvas" width="400" height="400"></canvas>
|
||||
</div>
|
||||
<div class="gps-legend">
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> GPS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff8800;"></span> Galileo</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff4466;"></span> BeiDou</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffdd00;"></span> SBAS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#cc66ff;"></span> QZSS</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (filled)</div>
|
||||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:transparent; border:1.5px solid #00d4ff;"></span> Unused (hollow)</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Position Info -->
|
||||
<div class="gps-position-panel">
|
||||
<h4>Position</h4>
|
||||
<div class="gps-pos-big">
|
||||
<div id="gpsVisPosLat">---</div>
|
||||
<div id="gpsVisPosLon">---</div>
|
||||
</div>
|
||||
<div style="margin-top: 4px;">
|
||||
<span class="gps-fix-badge no-fix" id="gpsVisFixBadge">NO FIX</span>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<div class="gps-pos-row">
|
||||
<span class="gps-pos-label">Altitude</span>
|
||||
<span class="gps-pos-value" id="gpsVisPosAlt">---</span>
|
||||
</div>
|
||||
<div class="gps-pos-row">
|
||||
<span class="gps-pos-label">Speed</span>
|
||||
<span class="gps-pos-value" id="gpsVisPosSpeed">---</span>
|
||||
</div>
|
||||
<div class="gps-pos-row">
|
||||
<span class="gps-pos-label">Heading</span>
|
||||
<span class="gps-pos-value" id="gpsVisPosHeading">---</span>
|
||||
</div>
|
||||
<div class="gps-pos-row">
|
||||
<span class="gps-pos-label">Climb</span>
|
||||
<span class="gps-pos-value" id="gpsVisPosClimb">---</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: auto; padding-top: 12px; border-top: 1px solid var(--border-color);">
|
||||
<div class="gps-pos-label">GPS TIME</div>
|
||||
<div class="gps-pos-value" id="gpsVisTime" style="font-size: 14px; color: var(--accent-cyan);">---</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Signal Strength Bars -->
|
||||
<div class="gps-signal-panel">
|
||||
<h4>Signal Strength (SNR dB-Hz)</h4>
|
||||
<div class="gps-signal-bars" id="gpsSignalBars"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
||||
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
||||
|
||||
@@ -2064,6 +2136,75 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- BT Locate SAR Dashboard -->
|
||||
<div id="btLocateVisuals" class="btl-visuals-container" style="display: none;">
|
||||
<!-- Proximity HUD -->
|
||||
<div class="btl-hud" id="btLocateHud" style="display: none;">
|
||||
<div class="btl-hud-top">
|
||||
<div class="btl-hud-band" id="btLocateBand">---</div>
|
||||
<div class="btl-hud-metrics">
|
||||
<div class="btl-hud-metric btl-hud-metric-lg">
|
||||
<span class="btl-hud-value" id="btLocateDistance">--</span>
|
||||
<span class="btl-hud-unit">m</span>
|
||||
<span class="btl-hud-label">Est. Distance</span>
|
||||
</div>
|
||||
<div class="btl-hud-metric">
|
||||
<span class="btl-hud-value" id="btLocateRssi">--</span>
|
||||
<span class="btl-hud-unit">dBm</span>
|
||||
<span class="btl-hud-label">RSSI</span>
|
||||
</div>
|
||||
<div class="btl-hud-metric">
|
||||
<span class="btl-hud-value" id="btLocateRssiEma">--</span>
|
||||
<span class="btl-hud-unit">dBm</span>
|
||||
<span class="btl-hud-label">RSSI avg</span>
|
||||
</div>
|
||||
<div class="btl-hud-separator"></div>
|
||||
<div class="btl-hud-metric">
|
||||
<span class="btl-hud-value" id="btLocateDetectionCount">0</span>
|
||||
<span class="btl-hud-unit"> </span>
|
||||
<span class="btl-hud-label">Detections</span>
|
||||
</div>
|
||||
<div class="btl-hud-metric">
|
||||
<span class="btl-hud-value" id="btLocateGpsCount">0</span>
|
||||
<span class="btl-hud-unit"> </span>
|
||||
<span class="btl-hud-label">GPS pts</span>
|
||||
</div>
|
||||
<div class="btl-hud-metric">
|
||||
<span class="btl-hud-value" id="btLocateSessionTime">0:00</span>
|
||||
<span class="btl-hud-unit"> </span>
|
||||
<span class="btl-hud-label">Duration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btl-hud-controls">
|
||||
<label class="btl-hud-audio-toggle">
|
||||
<input type="checkbox" id="btLocateAudioEnable" onchange="BtLocate.toggleAudio()">
|
||||
<span>Audio</span>
|
||||
</label>
|
||||
<button class="btl-hud-clear-btn" onclick="BtLocate.clearTrail()">Clear Trail</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btl-hud-bottom">
|
||||
<div class="btl-hud-info">
|
||||
<span class="btl-hud-info-item" id="btLocateTargetInfo">--</span>
|
||||
<span class="btl-hud-info-sep">·</span>
|
||||
<span class="btl-hud-info-item" id="btLocateEnvInfo">--</span>
|
||||
<span class="btl-hud-info-sep">·</span>
|
||||
<span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span>
|
||||
<span class="btl-hud-info-sep">·</span>
|
||||
<span class="btl-hud-info-item" id="btLocateLastSeen">Last: --</span>
|
||||
</div>
|
||||
<div id="btLocateDiag" class="btl-hud-diag"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btl-map-container">
|
||||
<div id="btLocateMap"></div>
|
||||
</div>
|
||||
<div class="btl-rssi-chart-container">
|
||||
<span class="btl-chart-label">RSSI History</span>
|
||||
<canvas id="btLocateRssiChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebSDR Dashboard -->
|
||||
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
|
||||
<!-- Audio Control Bar (hidden until connected) -->
|
||||
@@ -2831,7 +2972,7 @@
|
||||
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}?v={{ version }}&r=btlocate1"></script>
|
||||
<!-- WiFi v2 components -->
|
||||
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
|
||||
@@ -2841,9 +2982,11 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/gps.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -2978,8 +3121,8 @@
|
||||
let pendingStartMode = null;
|
||||
const validModes = new Set([
|
||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||
'spystations', 'meshtastic', 'wifi', 'bluetooth',
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'websdr', 'subghz'
|
||||
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz'
|
||||
]);
|
||||
|
||||
function getModeFromQuery() {
|
||||
@@ -3435,11 +3578,11 @@
|
||||
const modeGroups = {
|
||||
'pager': 'sdr', 'sensor': 'sdr',
|
||||
'aprs': 'sdr', 'listening': 'sdr',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
|
||||
'tscm': 'security',
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||
'meshtastic': 'sdr',
|
||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space',
|
||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
|
||||
'subghz': 'sdr'
|
||||
};
|
||||
|
||||
@@ -3513,7 +3656,7 @@
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate',
|
||||
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic',
|
||||
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv'
|
||||
};
|
||||
@@ -3530,8 +3673,10 @@
|
||||
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
||||
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
|
||||
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
|
||||
document.getElementById('gpsMode')?.classList.toggle('active', mode === 'gps');
|
||||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate');
|
||||
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
@@ -3569,8 +3714,10 @@
|
||||
'sstv': 'ISS SSTV',
|
||||
'weathersat': 'WEATHER SAT',
|
||||
'sstv_general': 'HF SSTV',
|
||||
'gps': 'GPS',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'bt_locate': 'BT LOCATE',
|
||||
'listening': 'LISTENING POST',
|
||||
'aprs': 'APRS',
|
||||
'tscm': 'TSCM',
|
||||
@@ -3594,9 +3741,11 @@
|
||||
const sstvVisuals = document.getElementById('sstvVisuals');
|
||||
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
|
||||
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
|
||||
const gpsVisuals = document.getElementById('gpsVisuals');
|
||||
const dmrVisuals = document.getElementById('dmrVisuals');
|
||||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
@@ -3608,9 +3757,11 @@
|
||||
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
|
||||
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
|
||||
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
|
||||
if (gpsVisuals) gpsVisuals.style.display = mode === 'gps' ? 'flex' : 'none';
|
||||
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
|
||||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||
|
||||
// Hide sidebar by default for Meshtastic mode, show for others
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
@@ -3637,8 +3788,10 @@
|
||||
'sstv': 'ISS SSTV Decoder',
|
||||
'weathersat': 'Weather Satellite Decoder',
|
||||
'sstv_general': 'HF SSTV Decoder',
|
||||
'gps': 'GPS Receiver',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'bt_locate': 'BT Locate — SAR Tracker',
|
||||
'listening': 'Listening Post',
|
||||
'aprs': 'APRS Tracker',
|
||||
'tscm': 'TSCM Counter-Surveillance',
|
||||
@@ -3665,7 +3818,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -3758,6 +3911,8 @@
|
||||
}, 100);
|
||||
} else if (mode === 'sstv_general') {
|
||||
SSTVGeneral.init();
|
||||
} else if (mode === 'gps') {
|
||||
GPS.init();
|
||||
} else if (mode === 'dmr') {
|
||||
if (typeof checkDmrTools === 'function') checkDmrTools();
|
||||
if (typeof checkDmrStatus === 'function') checkDmrStatus();
|
||||
@@ -3766,6 +3921,8 @@
|
||||
if (typeof initWebSDR === 'function') initWebSDR();
|
||||
} else if (mode === 'subghz') {
|
||||
SubGhz.init();
|
||||
} else if (mode === 'bt_locate') {
|
||||
BtLocate.init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3773,6 +3930,7 @@
|
||||
window.addEventListener('resize', function () {
|
||||
if (aprsMap) aprsMap.invalidateSize();
|
||||
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
||||
if (typeof BtLocate !== 'undefined') BtLocate.invalidateMap();
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', function () {
|
||||
|
||||
72
templates/partials/modes/bt_locate.html
Normal file
72
templates/partials/modes/bt_locate.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!-- BT LOCATE MODE -->
|
||||
<div id="btLocateMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>BT Locate</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
SAR Bluetooth device location — GPS-tagged signal trail mapping with proximity alerts for locating missing persons' devices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Target Lock -->
|
||||
<div class="section">
|
||||
<h3>Target</h3>
|
||||
<div id="btLocateHandoffCard" style="display: none; background: rgba(0,255,136,0.08); border: 1px solid rgba(0,255,136,0.3); border-radius: 6px; padding: 8px; margin-bottom: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 10px; color: var(--accent-green); text-transform: uppercase; font-weight: 600;">Handed off from BT</span>
|
||||
<button onclick="BtLocate.clearHandoff()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 10px;">×</button>
|
||||
</div>
|
||||
<div id="btLocateHandoffName" style="font-size: 12px; font-weight: 600; color: var(--text-primary); margin-top: 4px;"></div>
|
||||
<div id="btLocateHandoffMeta" style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);"></div>
|
||||
</div>
|
||||
|
||||
<label class="input-label">MAC Address</label>
|
||||
<input type="text" id="btLocateMac" class="text-input" placeholder="AA:BB:CC:DD:EE:FF" style="font-family: var(--font-mono); font-size: 11px;">
|
||||
|
||||
<label class="input-label" style="margin-top: 6px;">Name Pattern</label>
|
||||
<input type="text" id="btLocateNamePattern" class="text-input" placeholder="iPhone, Galaxy, etc.">
|
||||
|
||||
<label class="input-label" style="margin-top: 6px;">IRK (hex, optional)</label>
|
||||
<div style="display: flex; gap: 4px; align-items: center;">
|
||||
<input type="text" id="btLocateIrk" class="text-input" placeholder="32 hex chars for RPA resolution" style="font-family: var(--font-mono); font-size: 10px; flex: 1;">
|
||||
<button class="btl-detect-irk-btn" id="btLocateDetectIrkBtn" onclick="BtLocate.fetchPairedIrks()" title="Detect IRKs from paired devices">Detect</button>
|
||||
</div>
|
||||
<div id="btLocateIrkPicker" class="btl-irk-picker" style="display: none;">
|
||||
<div id="btLocateIrkPickerStatus" class="btl-irk-picker-status"></div>
|
||||
<div id="btLocateIrkPickerList" class="btl-irk-picker-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Preset -->
|
||||
<div class="section">
|
||||
<h3>Environment</h3>
|
||||
<div class="btl-env-grid">
|
||||
<button class="btl-env-btn" data-env="FREE_SPACE" onclick="BtLocate.setEnvironment('FREE_SPACE')">
|
||||
<span class="btl-env-icon">🏠</span>
|
||||
<span class="btl-env-label">Open Field</span>
|
||||
<span class="btl-env-n">n=2.0</span>
|
||||
</button>
|
||||
<button class="btl-env-btn active" data-env="OUTDOOR" onclick="BtLocate.setEnvironment('OUTDOOR')">
|
||||
<span class="btl-env-icon">🌳</span>
|
||||
<span class="btl-env-label">Outdoor</span>
|
||||
<span class="btl-env-n">n=2.2</span>
|
||||
</button>
|
||||
<button class="btl-env-btn" data-env="INDOOR" onclick="BtLocate.setEnvironment('INDOOR')">
|
||||
<span class="btl-env-icon">🏢</span>
|
||||
<span class="btl-env-label">Indoor</span>
|
||||
<span class="btl-env-n">n=3.0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="section">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="run-btn" id="btLocateStartBtn" onclick="BtLocate.start()">Start Locate</button>
|
||||
<button class="stop-btn" id="btLocateStopBtn" onclick="BtLocate.stop()" style="display: none;">Stop</button>
|
||||
</div>
|
||||
<div id="btLocateScanStatus" style="display: none; margin-top: 6px; font-size: 10px; color: var(--text-dim);">
|
||||
<span id="btLocateScanDot" style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; margin-right: 4px; vertical-align: middle;"></span>
|
||||
<span id="btLocateScanText">BT scanner active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
126
templates/partials/modes/gps.html
Normal file
126
templates/partials/modes/gps.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!-- GPS MODE -->
|
||||
<div id="gpsMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>GPS Receiver</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Display live GPS data from gpsd — satellite sky view, signal strengths, position, velocity, DOP values, and timing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection -->
|
||||
<div class="section">
|
||||
<h3>Connection</h3>
|
||||
<div class="gps-connection-status">
|
||||
<span class="gps-status-dot" id="gpsStatusDot"></span>
|
||||
<span class="gps-status-text" id="gpsStatusText">Disconnected</span>
|
||||
</div>
|
||||
<div id="gpsDevicePath" style="font-size: 10px; color: var(--text-dim); margin-top: 4px; font-family: var(--font-mono);"></div>
|
||||
<div style="display: flex; gap: 6px; margin-top: 8px;">
|
||||
<button class="run-btn" id="gpsConnectBtn" onclick="GPS.connect()">Connect</button>
|
||||
<button class="stop-btn" id="gpsDisconnectBtn" onclick="GPS.disconnect()" style="display: none;">Disconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fix Info -->
|
||||
<div class="section">
|
||||
<h3>Fix</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Fix Type</span>
|
||||
<span class="gps-info-value" id="gpsFixType">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Satellites</span>
|
||||
<span class="gps-info-value"><span id="gpsSatUsed">-</span> / <span id="gpsSatTotal">-</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Position -->
|
||||
<div class="section">
|
||||
<h3>Position</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Latitude</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsLat">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Longitude</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsLon">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Altitude</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsAlt">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Speed</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsSpeed">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Heading</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsHeading">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Climb</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsClimb">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DOP Values -->
|
||||
<div class="section">
|
||||
<h3>Dilution of Precision</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">HDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsHdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">VDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsVdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">PDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsPdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">TDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsTdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">GDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsGdop">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Estimates -->
|
||||
<div class="section">
|
||||
<h3>Error Estimates</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">EPH (horiz)</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsEph">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">EPV (vert)</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsEpv">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">EPS (speed)</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsEps">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timing -->
|
||||
<div class="section">
|
||||
<h3>GPS Time</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item" style="grid-column: 1 / -1;">
|
||||
<span class="gps-info-label">UTC</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsTime" style="font-size: 14px;">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,6 +87,7 @@
|
||||
<div class="mode-nav-dropdown-menu">
|
||||
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
|
||||
{{ mode_item('bluetooth', 'Bluetooth', '<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>') }}
|
||||
{{ mode_item('bt_locate', 'BT Locate', '<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>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +121,7 @@
|
||||
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
||||
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
{{ mode_item('gps', 'GPS', '<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"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +181,7 @@
|
||||
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
||||
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>') }}
|
||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{% if is_index_page %}
|
||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||
@@ -188,6 +191,7 @@
|
||||
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>') }}
|
||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||
|
||||
278
tests/test_bt_locate.py
Normal file
278
tests/test_bt_locate.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Tests for BT Locate — Bluetooth SAR Device Location System."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||
from cryptography.hazmat.primitives.ciphers import modes as cipher_modes
|
||||
HAS_CRYPTOGRAPHY = True
|
||||
except ImportError:
|
||||
HAS_CRYPTOGRAPHY = False
|
||||
|
||||
from utils.bt_locate import (
|
||||
DistanceEstimator,
|
||||
Environment,
|
||||
LocateSession,
|
||||
LocateTarget,
|
||||
get_locate_session,
|
||||
resolve_rpa,
|
||||
start_locate_session,
|
||||
stop_locate_session,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveRPA:
|
||||
"""Test BLE Resolvable Private Address resolution."""
|
||||
|
||||
@pytest.mark.skipif(not HAS_CRYPTOGRAPHY, reason="cryptography not installed")
|
||||
def test_resolve_rpa_valid_match(self):
|
||||
"""Test RPA resolution with known IRK/address pair.
|
||||
|
||||
Uses test vector: IRK = all zeros, we generate matching address.
|
||||
"""
|
||||
# The ah() function: encrypt(IRK, 0x00..00 || prand) then take last 3 bytes
|
||||
|
||||
irk = b'\x00' * 16
|
||||
# Choose prand with upper 2 bits = 01 (resolvable)
|
||||
prand = bytes([0x40, 0x00, 0x01])
|
||||
plaintext = b'\x00' * 13 + prand
|
||||
c = Cipher(algorithms.AES(irk), cipher_modes.ECB())
|
||||
enc = c.encryptor()
|
||||
encrypted = enc.update(plaintext) + enc.finalize()
|
||||
hash_bytes = encrypted[13:16]
|
||||
|
||||
# Build address: prand || hash
|
||||
addr_bytes = prand + hash_bytes
|
||||
address = ':'.join(f'{b:02X}' for b in addr_bytes)
|
||||
|
||||
assert resolve_rpa(irk, address) is True
|
||||
|
||||
def test_resolve_rpa_invalid_address(self):
|
||||
"""Test RPA resolution with non-matching address."""
|
||||
irk = b'\x00' * 16
|
||||
# Non-resolvable address (upper 2 bits != 01)
|
||||
assert resolve_rpa(irk, 'FF:FF:FF:FF:FF:FF') is False
|
||||
|
||||
@pytest.mark.skipif(not HAS_CRYPTOGRAPHY, reason="cryptography not installed")
|
||||
def test_resolve_rpa_wrong_irk(self):
|
||||
"""Test RPA resolution with wrong IRK."""
|
||||
irk = b'\x00' * 16
|
||||
prand = bytes([0x40, 0x00, 0x01])
|
||||
plaintext = b'\x00' * 13 + prand
|
||||
c = Cipher(algorithms.AES(irk), cipher_modes.ECB())
|
||||
enc = c.encryptor()
|
||||
encrypted = enc.update(plaintext) + enc.finalize()
|
||||
hash_bytes = encrypted[13:16]
|
||||
addr_bytes = prand + hash_bytes
|
||||
address = ':'.join(f'{b:02X}' for b in addr_bytes)
|
||||
|
||||
# Different IRK should fail
|
||||
wrong_irk = b'\x01' * 16
|
||||
assert resolve_rpa(wrong_irk, address) is False
|
||||
|
||||
def test_resolve_rpa_short_address(self):
|
||||
"""Test with invalid short address."""
|
||||
irk = b'\x00' * 16
|
||||
assert resolve_rpa(irk, 'AA:BB') is False
|
||||
|
||||
def test_resolve_rpa_empty(self):
|
||||
"""Test with empty inputs."""
|
||||
assert resolve_rpa(b'\x00' * 16, '') is False
|
||||
|
||||
|
||||
class TestDistanceEstimator:
|
||||
"""Test RSSI-to-distance estimation."""
|
||||
|
||||
def test_free_space_distance(self):
|
||||
estimator = DistanceEstimator(path_loss_exponent=2.0, rssi_at_1m=-59)
|
||||
# At 1m, RSSI should be -59, so distance should be ~1m
|
||||
d = estimator.estimate(-59)
|
||||
assert abs(d - 1.0) < 0.01
|
||||
|
||||
def test_weaker_signal_farther(self):
|
||||
estimator = DistanceEstimator(path_loss_exponent=2.0)
|
||||
d1 = estimator.estimate(-50)
|
||||
d2 = estimator.estimate(-70)
|
||||
assert d2 > d1
|
||||
|
||||
def test_indoor_closer_estimate(self):
|
||||
"""Indoor (n=3) should estimate closer distance for same RSSI."""
|
||||
free_space = DistanceEstimator(path_loss_exponent=2.0)
|
||||
indoor = DistanceEstimator(path_loss_exponent=3.0)
|
||||
rssi = -75
|
||||
d_free = free_space.estimate(rssi)
|
||||
d_indoor = indoor.estimate(rssi)
|
||||
# With higher path loss exponent, same RSSI means closer distance
|
||||
assert d_indoor < d_free
|
||||
|
||||
def test_proximity_band_immediate(self):
|
||||
assert DistanceEstimator.proximity_band(0.5) == 'IMMEDIATE'
|
||||
|
||||
def test_proximity_band_near(self):
|
||||
assert DistanceEstimator.proximity_band(3.0) == 'NEAR'
|
||||
|
||||
def test_proximity_band_far(self):
|
||||
assert DistanceEstimator.proximity_band(10.0) == 'FAR'
|
||||
|
||||
|
||||
class TestLocateTarget:
|
||||
"""Test target matching."""
|
||||
|
||||
def test_match_by_mac(self):
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
device = MagicMock()
|
||||
device.device_id = 'other'
|
||||
device.address = 'AA:BB:CC:DD:EE:FF'
|
||||
device.name = None
|
||||
assert target.matches(device) is True
|
||||
|
||||
def test_match_by_mac_case_insensitive(self):
|
||||
target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff')
|
||||
device = MagicMock()
|
||||
device.device_id = 'other'
|
||||
device.address = 'AA:BB:CC:DD:EE:FF'
|
||||
device.name = None
|
||||
assert target.matches(device) is True
|
||||
|
||||
def test_match_by_name_pattern(self):
|
||||
target = LocateTarget(name_pattern='iPhone')
|
||||
device = MagicMock()
|
||||
device.device_id = 'other'
|
||||
device.address = '00:00:00:00:00:00'
|
||||
device.name = "John's iPhone 15"
|
||||
assert target.matches(device) is True
|
||||
|
||||
def test_no_match(self):
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
device = MagicMock()
|
||||
device.device_id = 'other'
|
||||
device.address = '11:22:33:44:55:66'
|
||||
device.name = None
|
||||
assert target.matches(device) is False
|
||||
|
||||
def test_match_by_device_id(self):
|
||||
target = LocateTarget(device_id='my-device-123')
|
||||
device = MagicMock()
|
||||
device.device_id = 'my-device-123'
|
||||
device.address = '00:00:00:00:00:00'
|
||||
device.name = None
|
||||
assert target.matches(device) is True
|
||||
|
||||
def test_to_dict(self):
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF', known_name='Test')
|
||||
d = target.to_dict()
|
||||
assert d['mac_address'] == 'AA:BB:CC:DD:EE:FF'
|
||||
assert d['known_name'] == 'Test'
|
||||
|
||||
|
||||
class TestLocateSession:
|
||||
"""Test locate session lifecycle."""
|
||||
|
||||
@patch('utils.bt_locate.get_bluetooth_scanner')
|
||||
def test_start_stop(self, mock_get_scanner):
|
||||
mock_scanner = MagicMock()
|
||||
mock_get_scanner.return_value = mock_scanner
|
||||
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
session = LocateSession(target, Environment.OUTDOOR)
|
||||
session.start()
|
||||
|
||||
assert session.active is True
|
||||
mock_scanner.add_device_callback.assert_called_once()
|
||||
|
||||
session.stop()
|
||||
assert session.active is False
|
||||
mock_scanner.remove_device_callback.assert_called_once()
|
||||
|
||||
@patch('utils.bt_locate.get_bluetooth_scanner')
|
||||
@patch('utils.bt_locate.get_current_position')
|
||||
def test_detection_creates_trail_point(self, mock_gps, mock_get_scanner):
|
||||
mock_scanner = MagicMock()
|
||||
mock_get_scanner.return_value = mock_scanner
|
||||
mock_gps.return_value = None # No GPS
|
||||
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
session = LocateSession(target, Environment.OUTDOOR)
|
||||
session.start()
|
||||
|
||||
# Simulate device callback
|
||||
device = MagicMock()
|
||||
device.device_id = 'test'
|
||||
device.address = 'AA:BB:CC:DD:EE:FF'
|
||||
device.name = 'Test Device'
|
||||
device.rssi_current = -65
|
||||
|
||||
session._on_device(device)
|
||||
|
||||
assert session.detection_count == 1
|
||||
assert len(session.trail) == 1
|
||||
assert session.trail[0].rssi == -65
|
||||
|
||||
@patch('utils.bt_locate.get_bluetooth_scanner')
|
||||
def test_non_matching_device_ignored(self, mock_get_scanner):
|
||||
mock_scanner = MagicMock()
|
||||
mock_get_scanner.return_value = mock_scanner
|
||||
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
session = LocateSession(target, Environment.OUTDOOR)
|
||||
session.start()
|
||||
|
||||
device = MagicMock()
|
||||
device.device_id = 'other'
|
||||
device.address = '11:22:33:44:55:66'
|
||||
device.name = None
|
||||
device.rssi_current = -70
|
||||
|
||||
session._on_device(device)
|
||||
assert session.detection_count == 0
|
||||
|
||||
@patch('utils.bt_locate.get_bluetooth_scanner')
|
||||
def test_get_status(self, mock_get_scanner):
|
||||
mock_scanner = MagicMock()
|
||||
mock_get_scanner.return_value = mock_scanner
|
||||
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
session = LocateSession(target, Environment.FREE_SPACE)
|
||||
session.start()
|
||||
|
||||
status = session.get_status()
|
||||
assert status['active'] is True
|
||||
assert status['environment'] == 'FREE_SPACE'
|
||||
assert status['detection_count'] == 0
|
||||
|
||||
|
||||
class TestModuleLevelSessionManagement:
|
||||
"""Test module-level session functions."""
|
||||
|
||||
@patch('utils.bt_locate.get_bluetooth_scanner')
|
||||
def test_start_and_get_session(self, mock_get_scanner):
|
||||
mock_scanner = MagicMock()
|
||||
mock_get_scanner.return_value = mock_scanner
|
||||
|
||||
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
session = start_locate_session(target)
|
||||
|
||||
assert get_locate_session() is session
|
||||
assert session.active is True
|
||||
|
||||
stop_locate_session()
|
||||
assert get_locate_session() is None
|
||||
|
||||
@patch('utils.bt_locate.get_bluetooth_scanner')
|
||||
def test_start_replaces_existing_session(self, mock_get_scanner):
|
||||
mock_scanner = MagicMock()
|
||||
mock_get_scanner.return_value = mock_scanner
|
||||
|
||||
target1 = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
|
||||
session1 = start_locate_session(target1)
|
||||
|
||||
target2 = LocateTarget(mac_address='11:22:33:44:55:66')
|
||||
session2 = start_locate_session(target2)
|
||||
|
||||
assert get_locate_session() is session2
|
||||
assert session1.active is False
|
||||
assert session2.active is True
|
||||
|
||||
stop_locate_session()
|
||||
198
utils/bluetooth/irk_extractor.py
Normal file
198
utils/bluetooth/irk_extractor.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
IRK Extractor — Extract Identity Resolving Keys from paired Bluetooth devices.
|
||||
|
||||
Supports macOS (com.apple.Bluetooth.plist) and Linux (BlueZ info files).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger('intercept.bt.irk_extractor')
|
||||
|
||||
# Cache paired IRKs for 30 seconds to avoid repeated disk reads
|
||||
_cache: list[dict] | None = None
|
||||
_cache_time: float = 0
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
def get_paired_irks() -> list[dict]:
|
||||
"""Return paired Bluetooth devices that have IRKs.
|
||||
|
||||
Each entry is a dict with keys:
|
||||
- name: Device name (str or None)
|
||||
- address: Bluetooth address (str)
|
||||
- irk_hex: 32-char hex string of the 16-byte IRK
|
||||
- address_type: 'random' or 'public' (str or None)
|
||||
|
||||
Results are cached for 30 seconds.
|
||||
"""
|
||||
global _cache, _cache_time
|
||||
|
||||
now = time.monotonic()
|
||||
if _cache is not None and (now - _cache_time) < _CACHE_TTL:
|
||||
return _cache
|
||||
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == 'Darwin':
|
||||
results = _extract_macos()
|
||||
elif system == 'Linux':
|
||||
results = _extract_linux()
|
||||
else:
|
||||
logger.debug(f"IRK extraction not supported on {system}")
|
||||
results = []
|
||||
except Exception:
|
||||
logger.exception("Failed to extract paired IRKs")
|
||||
results = []
|
||||
|
||||
_cache = results
|
||||
_cache_time = now
|
||||
return results
|
||||
|
||||
|
||||
def _extract_macos() -> list[dict]:
|
||||
"""Extract IRKs from macOS Bluetooth plist."""
|
||||
import plistlib
|
||||
|
||||
plist_path = Path('/Library/Preferences/com.apple.Bluetooth.plist')
|
||||
if not plist_path.exists():
|
||||
logger.debug("macOS Bluetooth plist not found")
|
||||
return []
|
||||
|
||||
with open(plist_path, 'rb') as f:
|
||||
plist = plistlib.load(f)
|
||||
|
||||
devices = []
|
||||
|
||||
cache_data = plist.get('CoreBluetoothCache', {})
|
||||
|
||||
# CoreBluetoothCache contains BLE device info including IRKs
|
||||
for device_uuid, device_info in cache_data.items():
|
||||
if not isinstance(device_info, dict):
|
||||
continue
|
||||
|
||||
irk = device_info.get('IRK')
|
||||
if irk is None:
|
||||
continue
|
||||
|
||||
# IRK is stored as bytes (16 bytes)
|
||||
if isinstance(irk, bytes) and len(irk) == 16:
|
||||
irk_hex = irk.hex()
|
||||
elif isinstance(irk, str):
|
||||
irk_hex = irk.replace('-', '').replace(' ', '')
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
name = device_info.get('Name') or device_info.get('DeviceName')
|
||||
address = device_info.get('DeviceAddress', device_uuid)
|
||||
addr_type = 'random' if device_info.get('AddressType', 1) == 1 else 'public'
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': str(address),
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
|
||||
# Also check LEPairedDevices / PairedDevices structures
|
||||
for section_key in ('LEPairedDevices', 'PairedDevices'):
|
||||
section = plist.get(section_key, {})
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
for addr, dev_info in section.items():
|
||||
if not isinstance(dev_info, dict):
|
||||
continue
|
||||
irk = dev_info.get('IRK') or dev_info.get('IdentityResolvingKey')
|
||||
if irk is None:
|
||||
continue
|
||||
|
||||
if isinstance(irk, bytes) and len(irk) == 16:
|
||||
irk_hex = irk.hex()
|
||||
elif isinstance(irk, str):
|
||||
irk_hex = irk.replace('-', '').replace(' ', '')
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Skip if we already have this IRK
|
||||
if any(d['irk_hex'] == irk_hex for d in devices):
|
||||
continue
|
||||
|
||||
name = dev_info.get('Name') or dev_info.get('DeviceName')
|
||||
addr_type = 'random' if dev_info.get('AddressType', 1) == 1 else 'public'
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': str(addr),
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
|
||||
logger.info(f"Extracted {len(devices)} IRK(s) from macOS paired devices")
|
||||
return devices
|
||||
|
||||
|
||||
def _extract_linux() -> list[dict]:
|
||||
"""Extract IRKs from Linux BlueZ info files.
|
||||
|
||||
BlueZ stores paired device info at:
|
||||
/var/lib/bluetooth/<adapter_mac>/<device_mac>/info
|
||||
"""
|
||||
import configparser
|
||||
|
||||
bt_root = Path('/var/lib/bluetooth')
|
||||
if not bt_root.exists():
|
||||
logger.debug("BlueZ bluetooth directory not found")
|
||||
return []
|
||||
|
||||
devices = []
|
||||
|
||||
for adapter_dir in bt_root.iterdir():
|
||||
if not adapter_dir.is_dir():
|
||||
continue
|
||||
for device_dir in adapter_dir.iterdir():
|
||||
if not device_dir.is_dir():
|
||||
continue
|
||||
|
||||
info_file = device_dir / 'info'
|
||||
if not info_file.exists():
|
||||
continue
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(str(info_file))
|
||||
except (configparser.Error, OSError):
|
||||
continue
|
||||
|
||||
if not config.has_section('IdentityResolvingKey'):
|
||||
continue
|
||||
|
||||
irk_hex = config.get('IdentityResolvingKey', 'Key', fallback=None)
|
||||
if not irk_hex:
|
||||
continue
|
||||
|
||||
# BlueZ stores as hex string, may or may not have separators
|
||||
irk_hex = irk_hex.replace(' ', '').replace('-', '')
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
|
||||
name = config.get('General', 'Name', fallback=None)
|
||||
address = device_dir.name # Directory name is the MAC address
|
||||
addr_type = config.get('General', 'AddressType', fallback=None)
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': address,
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
|
||||
logger.info(f"Extracted {len(devices)} IRK(s) from BlueZ paired devices")
|
||||
return devices
|
||||
@@ -66,7 +66,7 @@ class BluetoothScanner:
|
||||
self._scan_timer: Optional[threading.Timer] = None
|
||||
|
||||
# Callbacks
|
||||
self._on_device_updated: Optional[Callable[[BTDeviceAggregate], None]] = None
|
||||
self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = []
|
||||
|
||||
# Capability check result
|
||||
self._capabilities: Optional[SystemCapabilities] = None
|
||||
@@ -236,9 +236,12 @@ class BluetoothScanner:
|
||||
'device': device.to_summary_dict(),
|
||||
})
|
||||
|
||||
# Callback
|
||||
if self._on_device_updated:
|
||||
self._on_device_updated(device)
|
||||
# Callbacks
|
||||
for cb in self._on_device_updated_callbacks:
|
||||
try:
|
||||
cb(device)
|
||||
except Exception as cb_err:
|
||||
logger.error(f"Device callback error: {cb_err}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling observation: {e}")
|
||||
@@ -368,13 +371,39 @@ class BluetoothScanner:
|
||||
return self._capabilities
|
||||
|
||||
def set_on_device_updated(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
||||
"""Set callback for device updates."""
|
||||
self._on_device_updated = callback
|
||||
"""Set callback for device updates (legacy, adds to callback list)."""
|
||||
self.add_device_callback(callback)
|
||||
|
||||
def add_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
||||
"""Add a callback for device updates."""
|
||||
if callback not in self._on_device_updated_callbacks:
|
||||
self._on_device_updated_callbacks.append(callback)
|
||||
|
||||
def remove_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
||||
"""Remove a device update callback."""
|
||||
if callback in self._on_device_updated_callbacks:
|
||||
self._on_device_updated_callbacks.remove(callback)
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
"""Check if scanning is active."""
|
||||
return self._status.is_scanning
|
||||
"""Check if scanning is active.
|
||||
|
||||
Cross-checks the backend scanner state, since bleak scans can
|
||||
expire silently without calling stop_scan().
|
||||
"""
|
||||
if not self._status.is_scanning:
|
||||
return False
|
||||
|
||||
# Detect backends that finished on their own (e.g. bleak timeout)
|
||||
backend_alive = (
|
||||
(self._dbus_scanner and self._dbus_scanner.is_scanning)
|
||||
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
|
||||
)
|
||||
if not backend_alive:
|
||||
self._status.is_scanning = False
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def device_count(self) -> int:
|
||||
|
||||
562
utils/bt_locate.py
Normal file
562
utils/bt_locate.py
Normal file
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
BT Locate — Bluetooth SAR Device Location System.
|
||||
|
||||
Provides GPS-tagged signal trail mapping, RPA resolution, environment-aware
|
||||
distance estimation, and proximity alerts for search and rescue operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from utils.bluetooth.models import BTDeviceAggregate
|
||||
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
|
||||
from utils.gps import get_current_position
|
||||
|
||||
logger = logging.getLogger('intercept.bt_locate')
|
||||
|
||||
# Maximum trail points to retain
|
||||
MAX_TRAIL_POINTS = 500
|
||||
|
||||
# EMA smoothing factor for RSSI
|
||||
EMA_ALPHA = 0.3
|
||||
|
||||
|
||||
class Environment(Enum):
|
||||
"""RF propagation environment presets."""
|
||||
FREE_SPACE = 2.0
|
||||
OUTDOOR = 2.2
|
||||
INDOOR = 3.0
|
||||
CUSTOM = 0.0 # user-provided exponent
|
||||
|
||||
|
||||
def resolve_rpa(irk: bytes, address: str) -> bool:
|
||||
"""
|
||||
Resolve a BLE Resolvable Private Address against an Identity Resolving Key.
|
||||
|
||||
Implements the Bluetooth Core Spec ah() function using AES-128-ECB.
|
||||
|
||||
Args:
|
||||
irk: 16-byte Identity Resolving Key.
|
||||
address: BLE address string (e.g. 'AA:BB:CC:DD:EE:FF').
|
||||
|
||||
Returns:
|
||||
True if the address resolves against the IRK.
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
except ImportError:
|
||||
logger.error("cryptography package required for RPA resolution")
|
||||
return False
|
||||
|
||||
# Parse address bytes (remove colons, convert to bytes)
|
||||
addr_bytes = bytes.fromhex(address.replace(':', '').replace('-', ''))
|
||||
if len(addr_bytes) != 6:
|
||||
return False
|
||||
|
||||
# RPA: upper 2 bits of MSB must be 01 (resolvable)
|
||||
if (addr_bytes[0] >> 6) != 1:
|
||||
return False
|
||||
|
||||
# prand = upper 3 bytes (MSB first), hash = lower 3 bytes
|
||||
prand = addr_bytes[0:3]
|
||||
expected_hash = addr_bytes[3:6]
|
||||
|
||||
# ah(k, r) = e(k, r') mod 2^24
|
||||
# r' is prand zero-padded to 16 bytes (MSB)
|
||||
plaintext = b'\x00' * 13 + prand
|
||||
|
||||
cipher = Cipher(algorithms.AES(irk), modes.ECB())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(plaintext) + encryptor.finalize()
|
||||
|
||||
# Take last 3 bytes as hash
|
||||
computed_hash = encrypted[13:16]
|
||||
|
||||
return computed_hash == expected_hash
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocateTarget:
|
||||
"""Target device specification for locate session."""
|
||||
mac_address: str | None = None
|
||||
name_pattern: str | None = None
|
||||
irk_hex: str | None = None
|
||||
device_id: str | None = None
|
||||
# Hand-off metadata from Bluetooth mode
|
||||
known_name: str | None = None
|
||||
known_manufacturer: str | None = None
|
||||
last_known_rssi: int | None = None
|
||||
|
||||
def matches(self, device: BTDeviceAggregate) -> bool:
|
||||
"""Check if a device matches this target."""
|
||||
# Match by device_id (exact)
|
||||
if self.device_id and device.device_id == self.device_id:
|
||||
return True
|
||||
|
||||
# Match by device_id address portion (without :address_type suffix)
|
||||
if self.device_id and ':' in self.device_id:
|
||||
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
|
||||
dev_addr = (device.address or '').upper()
|
||||
if target_addr_part and dev_addr == target_addr_part:
|
||||
return True
|
||||
|
||||
# Match by MAC/address (case-insensitive, normalize separators)
|
||||
if self.mac_address:
|
||||
dev_addr = (device.address or '').upper().replace('-', ':')
|
||||
target_addr = self.mac_address.upper().replace('-', ':')
|
||||
if dev_addr == target_addr:
|
||||
return True
|
||||
|
||||
# Match by RPA resolution
|
||||
if self.irk_hex:
|
||||
try:
|
||||
irk = bytes.fromhex(self.irk_hex)
|
||||
if len(irk) == 16 and device.address and resolve_rpa(irk, device.address):
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Match by name pattern
|
||||
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
|
||||
return True
|
||||
|
||||
# Match by known_name from handoff (exact name match)
|
||||
return bool(self.known_name and device.name and self.known_name.lower() == device.name.lower())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'mac_address': self.mac_address,
|
||||
'name_pattern': self.name_pattern,
|
||||
'irk_hex': self.irk_hex,
|
||||
'device_id': self.device_id,
|
||||
'known_name': self.known_name,
|
||||
'known_manufacturer': self.known_manufacturer,
|
||||
'last_known_rssi': self.last_known_rssi,
|
||||
}
|
||||
|
||||
|
||||
class DistanceEstimator:
|
||||
"""Estimate distance from RSSI using log-distance path loss model."""
|
||||
|
||||
# Reference RSSI at 1 meter (typical BLE)
|
||||
RSSI_AT_1M = -59
|
||||
|
||||
def __init__(self, path_loss_exponent: float = 2.0, rssi_at_1m: int = -59):
|
||||
self.n = path_loss_exponent
|
||||
self.rssi_at_1m = rssi_at_1m
|
||||
|
||||
def estimate(self, rssi: int) -> float:
|
||||
"""Estimate distance in meters from RSSI."""
|
||||
if rssi >= 0 or self.n <= 0:
|
||||
return 0.0
|
||||
return 10 ** ((self.rssi_at_1m - rssi) / (10 * self.n))
|
||||
|
||||
@staticmethod
|
||||
def proximity_band(distance: float) -> str:
|
||||
"""Classify distance into proximity band."""
|
||||
if distance <= 1.0:
|
||||
return 'IMMEDIATE'
|
||||
elif distance <= 5.0:
|
||||
return 'NEAR'
|
||||
else:
|
||||
return 'FAR'
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionPoint:
|
||||
"""A single GPS-tagged BLE detection."""
|
||||
timestamp: str
|
||||
rssi: int
|
||||
rssi_ema: float
|
||||
estimated_distance: float
|
||||
proximity_band: str
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
gps_accuracy: float | None = None
|
||||
rpa_resolved: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'timestamp': self.timestamp,
|
||||
'rssi': self.rssi,
|
||||
'rssi_ema': round(self.rssi_ema, 1),
|
||||
'estimated_distance': round(self.estimated_distance, 2),
|
||||
'proximity_band': self.proximity_band,
|
||||
'lat': self.lat,
|
||||
'lon': self.lon,
|
||||
'gps_accuracy': self.gps_accuracy,
|
||||
'rpa_resolved': self.rpa_resolved,
|
||||
}
|
||||
|
||||
|
||||
class LocateSession:
|
||||
"""Active locate session tracking a target device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: LocateTarget,
|
||||
environment: Environment = Environment.OUTDOOR,
|
||||
custom_exponent: float | None = None,
|
||||
fallback_lat: float | None = None,
|
||||
fallback_lon: float | None = None,
|
||||
):
|
||||
self.target = target
|
||||
self.environment = environment
|
||||
self.fallback_lat = fallback_lat
|
||||
self.fallback_lon = fallback_lon
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Distance estimator
|
||||
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
|
||||
self.estimator = DistanceEstimator(path_loss_exponent=n)
|
||||
|
||||
# Signal trail
|
||||
self.trail: list[DetectionPoint] = []
|
||||
|
||||
# RSSI EMA state
|
||||
self._rssi_ema: float | None = None
|
||||
|
||||
# SSE event queue
|
||||
self.event_queue: queue.Queue = queue.Queue(maxsize=500)
|
||||
|
||||
# Session state
|
||||
self.active = False
|
||||
self.started_at: datetime | None = None
|
||||
self.detection_count = 0
|
||||
self.last_detection: datetime | None = None
|
||||
|
||||
# Debug counters
|
||||
self.callback_call_count = 0
|
||||
self.poll_count = 0
|
||||
self._last_seen_device: str | None = None
|
||||
|
||||
# Scanner reference
|
||||
self._scanner: BluetoothScanner | None = None
|
||||
self._poll_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
# Track last RSSI per device to detect changes
|
||||
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the locate session.
|
||||
|
||||
Subscribes to scanner callbacks AND runs a polling thread that
|
||||
checks the aggregator directly (handles bleak scan timeout).
|
||||
"""
|
||||
self._scanner = get_bluetooth_scanner()
|
||||
self._scanner.add_device_callback(self._on_device)
|
||||
|
||||
# Ensure BLE scanning is active
|
||||
if not self._scanner.is_scanning:
|
||||
logger.info("BT scanner not running, starting scan for locate session")
|
||||
self._scanner_started_by_us = True
|
||||
if not self._scanner.start_scan(mode='auto'):
|
||||
logger.warning("Failed to start BT scanner for locate session")
|
||||
else:
|
||||
self._scanner_started_by_us = False
|
||||
|
||||
self.active = True
|
||||
self.started_at = datetime.now()
|
||||
self._stop_event.clear()
|
||||
|
||||
# Start polling thread as reliable fallback
|
||||
self._poll_thread = threading.Thread(
|
||||
target=self._poll_loop, daemon=True, name='bt-locate-poll'
|
||||
)
|
||||
self._poll_thread.start()
|
||||
|
||||
logger.info(f"Locate session started for target: {self.target.to_dict()}")
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the locate session."""
|
||||
self.active = False
|
||||
self._stop_event.set()
|
||||
if self._scanner:
|
||||
self._scanner.remove_device_callback(self._on_device)
|
||||
if getattr(self, '_scanner_started_by_us', False) and self._scanner.is_scanning:
|
||||
self._scanner.stop_scan()
|
||||
logger.info("Stopped BT scanner (was started by locate session)")
|
||||
if self._poll_thread:
|
||||
self._poll_thread.join(timeout=3.0)
|
||||
logger.info("Locate session stopped")
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Poll scanner aggregator for target device updates."""
|
||||
while not self._stop_event.is_set():
|
||||
self._stop_event.wait(timeout=1.5)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
self._check_aggregator()
|
||||
except Exception as e:
|
||||
logger.error(f"Locate poll error: {e}")
|
||||
|
||||
def _check_aggregator(self) -> None:
|
||||
"""Check the scanner's aggregator for the target device."""
|
||||
if not self._scanner:
|
||||
return
|
||||
|
||||
self.poll_count += 1
|
||||
|
||||
# Restart scan if it expired (bleak 10s timeout)
|
||||
if not self._scanner.is_scanning:
|
||||
logger.info("Scanner stopped, restarting for locate session")
|
||||
self._scanner.start_scan(mode='auto')
|
||||
|
||||
# Check devices seen within a recent window. Using a short window
|
||||
# (rather than the aggregator's full 120s) so that once a device
|
||||
# goes silent its stale RSSI stops producing detections. The window
|
||||
# must survive bleak's 10s scan cycle + restart gap (~3s).
|
||||
devices = self._scanner.get_devices(max_age_seconds=15)
|
||||
found_target = False
|
||||
for device in devices:
|
||||
if not self.target.matches(device):
|
||||
continue
|
||||
found_target = True
|
||||
rssi = device.rssi_current
|
||||
if rssi is None:
|
||||
continue
|
||||
self._record_detection(device, rssi)
|
||||
break # One match per poll cycle is sufficient
|
||||
|
||||
# Log periodically for debugging
|
||||
if self.poll_count % 20 == 0 or (self.poll_count <= 5) or not found_target:
|
||||
logger.info(
|
||||
f"Poll #{self.poll_count}: {len(devices)} devices, "
|
||||
f"target_found={found_target}, "
|
||||
f"detections={self.detection_count}, "
|
||||
f"scanning={self._scanner.is_scanning}"
|
||||
)
|
||||
|
||||
def _on_device(self, device: BTDeviceAggregate) -> None:
|
||||
"""Scanner callback: check if device matches target."""
|
||||
if not self.active:
|
||||
return
|
||||
|
||||
self.callback_call_count += 1
|
||||
self._last_seen_device = f"{device.device_id}|{device.name}"
|
||||
|
||||
if not self.target.matches(device):
|
||||
return
|
||||
|
||||
rssi = device.rssi_current
|
||||
if rssi is None:
|
||||
return
|
||||
|
||||
# Dedup rapid callbacks (bleak can fire many times per second)
|
||||
prev = self._last_cb_rssi.get(device.device_id)
|
||||
if prev == rssi:
|
||||
return
|
||||
self._last_cb_rssi[device.device_id] = rssi
|
||||
self._record_detection(device, rssi)
|
||||
|
||||
def _record_detection(self, device: BTDeviceAggregate, rssi: int) -> None:
|
||||
"""Record a target detection with GPS tagging."""
|
||||
logger.info(f"Target detected: {device.address} RSSI={rssi} name={device.name}")
|
||||
|
||||
# Update EMA
|
||||
if self._rssi_ema is None:
|
||||
self._rssi_ema = float(rssi)
|
||||
else:
|
||||
self._rssi_ema = EMA_ALPHA * rssi + (1 - EMA_ALPHA) * self._rssi_ema
|
||||
|
||||
# Estimate distance
|
||||
distance = self.estimator.estimate(rssi)
|
||||
band = DistanceEstimator.proximity_band(distance)
|
||||
|
||||
# Check RPA resolution
|
||||
rpa_resolved = False
|
||||
if self.target.irk_hex and device.address:
|
||||
try:
|
||||
irk = bytes.fromhex(self.target.irk_hex)
|
||||
rpa_resolved = resolve_rpa(irk, device.address)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# GPS tag — prefer live GPS, fall back to user-set coordinates
|
||||
gps_pos = get_current_position()
|
||||
lat = gps_pos.latitude if gps_pos else None
|
||||
lon = gps_pos.longitude if gps_pos else None
|
||||
gps_acc = None
|
||||
if gps_pos:
|
||||
epx = gps_pos.epx or 0
|
||||
epy = gps_pos.epy or 0
|
||||
if epx or epy:
|
||||
gps_acc = round(max(epx, epy), 1)
|
||||
elif self.fallback_lat is not None and self.fallback_lon is not None:
|
||||
lat = self.fallback_lat
|
||||
lon = self.fallback_lon
|
||||
|
||||
now = datetime.now()
|
||||
point = DetectionPoint(
|
||||
timestamp=now.isoformat(),
|
||||
rssi=rssi,
|
||||
rssi_ema=self._rssi_ema,
|
||||
estimated_distance=distance,
|
||||
proximity_band=band,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
gps_accuracy=gps_acc,
|
||||
rpa_resolved=rpa_resolved,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self.trail.append(point)
|
||||
if len(self.trail) > MAX_TRAIL_POINTS:
|
||||
self.trail = self.trail[-MAX_TRAIL_POINTS:]
|
||||
self.detection_count += 1
|
||||
self.last_detection = now
|
||||
|
||||
# Queue SSE event
|
||||
event = {
|
||||
'type': 'detection',
|
||||
'data': point.to_dict(),
|
||||
'device_name': device.name,
|
||||
'device_address': device.address,
|
||||
}
|
||||
try:
|
||||
self.event_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
self.event_queue.get_nowait()
|
||||
self.event_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def get_trail(self) -> list[dict]:
|
||||
"""Get the full detection trail."""
|
||||
with self._lock:
|
||||
return [p.to_dict() for p in self.trail]
|
||||
|
||||
def get_gps_trail(self) -> list[dict]:
|
||||
"""Get only trail points that have GPS coordinates."""
|
||||
with self._lock:
|
||||
return [p.to_dict() for p in self.trail if p.lat is not None]
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get session status."""
|
||||
gps_pos = get_current_position()
|
||||
|
||||
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
|
||||
# deadlock: get_status would hold self._lock then wait on
|
||||
# aggregator._lock, while _poll_loop holds aggregator._lock then
|
||||
# waits on self._lock in _record_detection.
|
||||
debug_devices = self._debug_device_sample()
|
||||
scanner_running = self._scanner.is_scanning if self._scanner else False
|
||||
scanner_device_count = self._scanner.device_count if self._scanner else 0
|
||||
callback_registered = (
|
||||
self._on_device in self._scanner._on_device_updated_callbacks
|
||||
if self._scanner else False
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
return {
|
||||
'active': self.active,
|
||||
'target': self.target.to_dict(),
|
||||
'environment': self.environment.name,
|
||||
'path_loss_exponent': self.estimator.n,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'detection_count': self.detection_count,
|
||||
'gps_trail_count': sum(1 for p in self.trail if p.lat is not None),
|
||||
'last_detection': self.last_detection.isoformat() if self.last_detection else None,
|
||||
'scanner_running': scanner_running,
|
||||
'scanner_device_count': scanner_device_count,
|
||||
'callback_registered': callback_registered,
|
||||
'event_queue_size': self.event_queue.qsize(),
|
||||
'callback_call_count': self.callback_call_count,
|
||||
'poll_count': self.poll_count,
|
||||
'poll_thread_alive': self._poll_thread.is_alive() if self._poll_thread else False,
|
||||
'last_seen_device': self._last_seen_device,
|
||||
'gps_available': gps_pos is not None,
|
||||
'gps_source': 'live' if gps_pos else (
|
||||
'manual' if self.fallback_lat is not None else 'none'
|
||||
),
|
||||
'fallback_lat': self.fallback_lat,
|
||||
'fallback_lon': self.fallback_lon,
|
||||
'latest_rssi': self.trail[-1].rssi if self.trail else None,
|
||||
'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None,
|
||||
'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None,
|
||||
'latest_band': self.trail[-1].proximity_band if self.trail else None,
|
||||
'debug_devices': debug_devices,
|
||||
}
|
||||
|
||||
def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None:
|
||||
"""Update the environment and recalculate distance estimator."""
|
||||
with self._lock:
|
||||
self.environment = environment
|
||||
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
|
||||
self.estimator = DistanceEstimator(path_loss_exponent=n)
|
||||
|
||||
def _debug_device_sample(self) -> list[dict]:
|
||||
"""Return a sample of scanner devices for debugging matching issues."""
|
||||
if not self._scanner:
|
||||
return []
|
||||
try:
|
||||
devices = self._scanner.get_devices(max_age_seconds=30)
|
||||
return [
|
||||
{
|
||||
'id': d.device_id,
|
||||
'addr': d.address,
|
||||
'name': d.name,
|
||||
'rssi': d.rssi_current,
|
||||
'match': self.target.matches(d),
|
||||
}
|
||||
for d in devices[:8]
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def clear_trail(self) -> None:
|
||||
"""Clear the detection trail."""
|
||||
with self._lock:
|
||||
self.trail.clear()
|
||||
self.detection_count = 0
|
||||
|
||||
|
||||
# Module-level session management (single active session)
|
||||
_session: LocateSession | None = None
|
||||
_session_lock = threading.Lock()
|
||||
|
||||
|
||||
def start_locate_session(
|
||||
target: LocateTarget,
|
||||
environment: Environment = Environment.OUTDOOR,
|
||||
custom_exponent: float | None = None,
|
||||
fallback_lat: float | None = None,
|
||||
fallback_lon: float | None = None,
|
||||
) -> LocateSession:
|
||||
"""Start a new locate session, stopping any existing one."""
|
||||
global _session
|
||||
|
||||
with _session_lock:
|
||||
if _session and _session.active:
|
||||
_session.stop()
|
||||
|
||||
_session = LocateSession(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
_session.start()
|
||||
return _session
|
||||
|
||||
|
||||
def stop_locate_session() -> None:
|
||||
"""Stop the active locate session."""
|
||||
global _session
|
||||
|
||||
with _session_lock:
|
||||
if _session:
|
||||
_session.stop()
|
||||
_session = None
|
||||
|
||||
|
||||
def get_locate_session() -> LocateSession | None:
|
||||
"""Get the current locate session (if any)."""
|
||||
with _session_lock:
|
||||
return _session
|
||||
213
utils/gps.py
213
utils/gps.py
@@ -6,28 +6,86 @@ Provides GPS location data by connecting to the gpsd daemon.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import socket as _socket_mod
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger('intercept.gps')
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSSatellite:
|
||||
"""Individual satellite data from gpsd SKY message."""
|
||||
prn: int
|
||||
elevation: float | None = None # degrees
|
||||
azimuth: float | None = None # degrees
|
||||
snr: float | None = None # dB-Hz
|
||||
used: bool = False
|
||||
constellation: str = 'GPS' # GPS, GLONASS, Galileo, BeiDou, SBAS, QZSS
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'prn': self.prn,
|
||||
'elevation': self.elevation,
|
||||
'azimuth': self.azimuth,
|
||||
'snr': self.snr,
|
||||
'used': self.used,
|
||||
'constellation': self.constellation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSSkyData:
|
||||
"""Sky view data from gpsd SKY message."""
|
||||
satellites: list[GPSSatellite] = field(default_factory=list)
|
||||
hdop: float | None = None
|
||||
vdop: float | None = None
|
||||
pdop: float | None = None
|
||||
tdop: float | None = None
|
||||
gdop: float | None = None
|
||||
xdop: float | None = None
|
||||
ydop: float | None = None
|
||||
nsat: int = 0 # total visible
|
||||
usat: int = 0 # total used
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'satellites': [s.to_dict() for s in self.satellites],
|
||||
'hdop': self.hdop,
|
||||
'vdop': self.vdop,
|
||||
'pdop': self.pdop,
|
||||
'tdop': self.tdop,
|
||||
'gdop': self.gdop,
|
||||
'xdop': self.xdop,
|
||||
'ydop': self.ydop,
|
||||
'nsat': self.nsat,
|
||||
'usat': self.usat,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSPosition:
|
||||
"""GPS position data."""
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: Optional[float] = None
|
||||
speed: Optional[float] = None # m/s
|
||||
heading: Optional[float] = None # degrees
|
||||
satellites: Optional[int] = None
|
||||
altitude: float | None = None
|
||||
speed: float | None = None # m/s
|
||||
heading: float | None = None # degrees
|
||||
climb: float | None = None # m/s vertical speed
|
||||
satellites: int | None = None
|
||||
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||
timestamp: Optional[datetime] = None
|
||||
device: Optional[str] = None
|
||||
timestamp: datetime | None = None
|
||||
device: str | None = None
|
||||
# Error estimates
|
||||
epx: float | None = None # lon error (m)
|
||||
epy: float | None = None # lat error (m)
|
||||
epv: float | None = None # vertical error (m)
|
||||
eps: float | None = None # speed error (m/s)
|
||||
ept: float | None = None # time error (s)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
@@ -37,13 +95,45 @@ class GPSPosition:
|
||||
'altitude': self.altitude,
|
||||
'speed': self.speed,
|
||||
'heading': self.heading,
|
||||
'climb': self.climb,
|
||||
'satellites': self.satellites,
|
||||
'fix_quality': self.fix_quality,
|
||||
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||
'device': self.device,
|
||||
'epx': self.epx,
|
||||
'epy': self.epy,
|
||||
'epv': self.epv,
|
||||
'eps': self.eps,
|
||||
'ept': self.ept,
|
||||
}
|
||||
|
||||
|
||||
def _classify_constellation(prn: int, gnssid: int | None = None) -> str:
|
||||
"""Classify satellite constellation from PRN or gnssid."""
|
||||
if gnssid is not None:
|
||||
mapping = {
|
||||
0: 'GPS', 1: 'SBAS', 2: 'Galileo', 3: 'BeiDou',
|
||||
4: 'IMES', 5: 'QZSS', 6: 'GLONASS', 7: 'NavIC',
|
||||
}
|
||||
return mapping.get(gnssid, 'GPS')
|
||||
# Fall back to PRN range heuristic
|
||||
if 1 <= prn <= 32:
|
||||
return 'GPS'
|
||||
elif 33 <= prn <= 64:
|
||||
return 'SBAS'
|
||||
elif 65 <= prn <= 96:
|
||||
return 'GLONASS'
|
||||
elif 120 <= prn <= 158:
|
||||
return 'SBAS'
|
||||
elif 201 <= prn <= 264:
|
||||
return 'BeiDou'
|
||||
elif 301 <= prn <= 336:
|
||||
return 'Galileo'
|
||||
elif 193 <= prn <= 200:
|
||||
return 'QZSS'
|
||||
return 'GPS'
|
||||
|
||||
|
||||
class GPSDClient:
|
||||
"""
|
||||
Connects to gpsd daemon for GPS data.
|
||||
@@ -58,35 +148,43 @@ class GPSDClient:
|
||||
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._position: Optional[GPSPosition] = None
|
||||
self._position: GPSPosition | None = None
|
||||
self._sky: GPSSkyData | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._socket: Optional['socket.socket'] = None
|
||||
self._last_update: Optional[datetime] = None
|
||||
self._error: Optional[str] = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._socket: _socket_mod.socket | None = None
|
||||
self._last_update: datetime | None = None
|
||||
self._error: str | None = None
|
||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
||||
self._device: Optional[str] = None
|
||||
self._sky_callbacks: list[Callable[[GPSSkyData], None]] = []
|
||||
self._device: str | None = None
|
||||
|
||||
@property
|
||||
def position(self) -> Optional[GPSPosition]:
|
||||
def position(self) -> GPSPosition | None:
|
||||
"""Get the current GPS position."""
|
||||
with self._lock:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def sky(self) -> GPSSkyData | None:
|
||||
"""Get the current sky view data."""
|
||||
with self._lock:
|
||||
return self._sky
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the client is running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_update(self) -> Optional[datetime]:
|
||||
def last_update(self) -> datetime | None:
|
||||
"""Get the time of the last position update."""
|
||||
with self._lock:
|
||||
return self._last_update
|
||||
|
||||
@property
|
||||
def error(self) -> Optional[str]:
|
||||
def error(self) -> str | None:
|
||||
"""Get any error message."""
|
||||
with self._lock:
|
||||
return self._error
|
||||
@@ -105,6 +203,15 @@ class GPSDClient:
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
|
||||
"""Add a callback to be called on sky data updates."""
|
||||
self._sky_callbacks.append(callback)
|
||||
|
||||
def remove_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
|
||||
"""Remove a sky data update callback."""
|
||||
if callback in self._sky_callbacks:
|
||||
self._sky_callbacks.remove(callback)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start receiving GPS data from gpsd."""
|
||||
import socket
|
||||
@@ -135,10 +242,8 @@ class GPSDClient:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
|
||||
if self._socket:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
@@ -169,7 +274,7 @@ class GPSDClient:
|
||||
buffer = ""
|
||||
message_count = 0
|
||||
|
||||
print(f"[GPS] gpsd read loop started", flush=True)
|
||||
print("[GPS] gpsd read loop started", flush=True)
|
||||
|
||||
while self._running and self._socket:
|
||||
try:
|
||||
@@ -202,6 +307,8 @@ class GPSDClient:
|
||||
|
||||
if msg_class == 'TPV':
|
||||
self._handle_tpv(msg)
|
||||
elif msg_class == 'SKY':
|
||||
self._handle_sky(msg)
|
||||
elif msg_class == 'DEVICES':
|
||||
# Track connected device
|
||||
devices = msg.get('devices', [])
|
||||
@@ -239,11 +346,9 @@ class GPSDClient:
|
||||
timestamp = None
|
||||
time_str = msg.get('time')
|
||||
if time_str:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, AttributeError):
|
||||
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
|
||||
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
position = GPSPosition(
|
||||
latitude=lat,
|
||||
@@ -251,14 +356,58 @@ class GPSDClient:
|
||||
altitude=msg.get('alt'),
|
||||
speed=msg.get('speed'), # m/s in gpsd
|
||||
heading=msg.get('track'),
|
||||
climb=msg.get('climb'),
|
||||
fix_quality=mode,
|
||||
timestamp=timestamp,
|
||||
device=self._device or f"gpsd://{self.host}:{self.port}",
|
||||
epx=msg.get('epx'),
|
||||
epy=msg.get('epy'),
|
||||
epv=msg.get('epv'),
|
||||
eps=msg.get('eps'),
|
||||
ept=msg.get('ept'),
|
||||
)
|
||||
|
||||
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
|
||||
self._update_position(position)
|
||||
|
||||
def _handle_sky(self, msg: dict) -> None:
|
||||
"""Handle SKY (satellite sky view) message from gpsd."""
|
||||
sats = []
|
||||
for sat in msg.get('satellites', []):
|
||||
prn = sat.get('PRN', 0)
|
||||
gnssid = sat.get('gnssid')
|
||||
sats.append(GPSSatellite(
|
||||
prn=prn,
|
||||
elevation=sat.get('el'),
|
||||
azimuth=sat.get('az'),
|
||||
snr=sat.get('ss'),
|
||||
used=sat.get('used', False),
|
||||
constellation=_classify_constellation(prn, gnssid),
|
||||
))
|
||||
|
||||
sky_data = GPSSkyData(
|
||||
satellites=sats,
|
||||
hdop=msg.get('hdop'),
|
||||
vdop=msg.get('vdop'),
|
||||
pdop=msg.get('pdop'),
|
||||
tdop=msg.get('tdop'),
|
||||
gdop=msg.get('gdop'),
|
||||
xdop=msg.get('xdop'),
|
||||
ydop=msg.get('ydop'),
|
||||
nsat=len(sats),
|
||||
usat=sum(1 for s in sats if s.used),
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._sky = sky_data
|
||||
|
||||
# Notify sky callbacks
|
||||
for callback in self._sky_callbacks:
|
||||
try:
|
||||
callback(sky_data)
|
||||
except Exception as e:
|
||||
logger.error(f"GPS sky callback error: {e}")
|
||||
|
||||
def _update_position(self, position: GPSPosition) -> None:
|
||||
"""Update the current position and notify callbacks."""
|
||||
with self._lock:
|
||||
@@ -275,18 +424,19 @@ class GPSDClient:
|
||||
|
||||
|
||||
# Global GPS client instance
|
||||
_gps_client: Optional[GPSDClient] = None
|
||||
_gps_client: GPSDClient | None = None
|
||||
_gps_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gps_reader() -> Optional[GPSDClient]:
|
||||
def get_gps_reader() -> GPSDClient | None:
|
||||
"""Get the global GPS client instance."""
|
||||
with _gps_lock:
|
||||
return _gps_client
|
||||
|
||||
|
||||
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
callback: Callable[[GPSPosition], None] | None = None,
|
||||
sky_callback: Callable[[GPSSkyData], None] | None = None) -> bool:
|
||||
"""
|
||||
Start the global GPS client connected to gpsd.
|
||||
|
||||
@@ -294,6 +444,7 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
host: gpsd host (default localhost)
|
||||
port: gpsd port (default 2947)
|
||||
callback: Optional callback for position updates
|
||||
sky_callback: Optional callback for sky data updates
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
@@ -307,9 +458,11 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
|
||||
_gps_client = GPSDClient(host, port)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
# Register callbacks BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_client.add_callback(callback)
|
||||
if sky_callback:
|
||||
_gps_client.add_sky_callback(sky_callback)
|
||||
|
||||
return _gps_client.start()
|
||||
|
||||
@@ -324,7 +477,7 @@ def stop_gps() -> None:
|
||||
_gps_client = None
|
||||
|
||||
|
||||
def get_current_position() -> Optional[GPSPosition]:
|
||||
def get_current_position() -> GPSPosition | None:
|
||||
"""Get the current GPS position from the global client."""
|
||||
client = get_gps_reader()
|
||||
if client:
|
||||
|
||||
Reference in New Issue
Block a user