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