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 AIS Vessel Tracking
+
+ BT Locate SAR Tracker + 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 + '
' + + '
' + + '' + + '
' + ''; } @@ -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 + @@ -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' %} + + @@ -1043,6 +1054,67 @@ + + + + + +