diff --git a/README.md b/README.md
index 83dc4c3..272ca11 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 7afae64..1b752fb 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -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.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index f89fe6e..a07acff 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -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
diff --git a/docs/images/bt-locate.png b/docs/images/bt-locate.png
new file mode 100644
index 0000000..b434f6e
Binary files /dev/null and b/docs/images/bt-locate.png differ
diff --git a/docs/index.html b/docs/index.html
index 6727bc8..ca86413 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -107,6 +107,18 @@
Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.
+
+
📍
+
BT Locate
+
SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.
+
+
+
+
🛰️
+
GPS Tracking
+
Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.
+
+
🛡️
TSCM
@@ -236,6 +248,10 @@
AIS Vessel Tracking
+
+
+
BT Locate — SAR Tracker
+
diff --git a/intercept_agent.py b/intercept_agent.py
index ac635a3..5c3ed41 100644
--- a/intercept_agent.py
+++ b/intercept_agent.py
@@ -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)
diff --git a/requirements.txt b/requirements.txt
index 911afde..cb05331 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/routes/__init__.py b/routes/__init__.py
index a10c0a4..49d1783 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -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
diff --git a/routes/bluetooth_v2.py b/routes/bluetooth_v2.py
index 24ace6e..393ff7a 100644
--- a/routes/bluetooth_v2.py
+++ b/routes/bluetooth_v2.py
@@ -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:
diff --git a/routes/bt_locate.py b/routes/bt_locate.py
new file mode 100644
index 0000000..eec8e13
--- /dev/null
+++ b/routes/bt_locate.py
@@ -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'})
diff --git a/routes/gps.py b/routes/gps.py
index a5f8b64..4685311 100644
--- a/routes/gps.py
+++ b/routes/gps.py
@@ -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:
diff --git a/static/css/index.css b/static/css/index.css
index 8e90f7a..d485dd9 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -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;
diff --git a/static/css/modes/bt_locate.css b/static/css/modes/bt_locate.css
new file mode 100644
index 0000000..abaf712
--- /dev/null
+++ b/static/css/modes/bt_locate.css
@@ -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;
+ }
+}
diff --git a/static/css/modes/gps.css b/static/css/modes/gps.css
new file mode 100644
index 0000000..21850c1
--- /dev/null
+++ b/static/css/modes/gps.css
@@ -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;
+ }
+}
diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js
index 16aa152..7968b9f 100644
--- a/static/js/modes/bluetooth.js
+++ b/static/js/modes/bluetooth.js
@@ -366,10 +366,10 @@ const BluetoothMode = (function() {
// Badges
const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `${protocol.toUpperCase()} `;
- badgesHtml += `${device.in_baseline ? '✓ KNOWN' : '● NEW'} `;
- if (device.seen_before) {
- badgesHtml += `SEEN BEFORE `;
- }
+ badgesHtml += `${device.in_baseline ? '✓ KNOWN' : '● NEW'} `;
+ if (device.seen_before) {
+ badgesHtml += `SEEN BEFORE `;
+ }
// 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('SEEN BEFORE ');
+ if (mfr) secondaryParts.push(mfr);
+ secondaryParts.push('Seen ' + seenCount + '×');
+ if (seenBefore) secondaryParts.push('SEEN BEFORE ');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('' + escapeHtml(agentName) + ' ');
@@ -1216,6 +1216,11 @@ const BluetoothMode = (function() {
'' +
'' +
'' + secondaryInfo + '
' +
+ '' +
+ '
' +
+ ' ' +
+ 'Locate ' +
+ '
' +
'';
}
@@ -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,
diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js
new file mode 100644
index 0000000..41b822a
--- /dev/null
+++ b/static/js/modes/bt_locate.js
@@ -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(
+ '' +
+ '' + point.proximity_band + ' ' +
+ 'RSSI: ' + point.rssi + ' dBm ' +
+ 'Distance: ~' + point.estimated_distance.toFixed(1) + ' m ' +
+ 'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
+ '
'
+ );
+
+ 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 =
+ '' + (dev.name || 'Unknown Device') + '
' +
+ '' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '
';
+ 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,
+ };
+})();
diff --git a/static/js/modes/gps.js b/static/js/modes/gps.js
new file mode 100644
index 0000000..a65db27
--- /dev/null
+++ b/static/js/modes/gps.js
@@ -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 = '3D FIX ';
+ else if (fq === 2) fixEl.innerHTML = '2D FIX ';
+ else fixEl.innerHTML = 'NO FIX ';
+ }
+
+ // 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,
+ };
+})();
diff --git a/templates/index.html b/templates/index.html
index cf39369..f17e03e 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -63,7 +63,9 @@
+
+
@@ -248,6 +250,10 @@
HF SSTV
+
+
+ GPS
+
@@ -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' %}
+
Kill All Processes
@@ -828,6 +838,7 @@
Watchlist
Copy
+ Locate
@@ -1043,6 +1054,67 @@
+
+
+
+
+
+
Satellite Sky View
+
+
+
+
+
GPS
+
GLONASS
+
Galileo
+
BeiDou
+
SBAS
+
QZSS
+
Used (filled)
+
Unused (hollow)
+
+
+
+
+
Position
+
+
+ NO FIX
+
+
+
+ Altitude
+ ---
+
+
+ Speed
+ ---
+
+
+ Heading
+ ---
+
+
+ Climb
+ ---
+
+
+
+
+
+
+
+
Signal Strength (SNR dB-Hz)
+
+
+
+
@@ -2064,6 +2136,75 @@
+
+
+
+
+
+
---
+
+
+ --
+ m
+ Est. Distance
+
+
+
+ dBm
+ RSSI
+
+
+
+ dBm
+ RSSI avg
+
+
+
+ 0
+
+ Detections
+
+
+ 0
+
+ GPS pts
+
+
+ 0:00
+
+ Duration
+
+
+
+
+
+ Audio
+
+ Clear Trail
+
+
+
+
+ --
+ ·
+ --
+ ·
+ GPS: --
+ ·
+ Last: --
+
+
+
+
+
+
+
+
@@ -2831,7 +2972,7 @@
-
+
@@ -2841,9 +2982,11 @@
+
+