Release v2.22.0

Waterfall overhaul, new modes (fingerprint, RF heatmap, SignalID, voice
alerts), PWA support, mode stop responsiveness improvements, ADS-B MSG2
surface tracking, WebSDR overhaul, and full documentation audit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-23 19:31:10 +00:00
parent 3acdab816a
commit 9705e58691
15 changed files with 647 additions and 448 deletions
+72 -31
View File
@@ -1,36 +1,77 @@
# Changelog # Changelog
All notable changes to iNTERCEPT will be documented in this file. All notable changes to iNTERCEPT will be documented in this file.
## [2.21.1] - 2026-02-20 ## [2.22.0] - 2026-02-23
### Fixed ### Added
- BT Locate map first-load rendering race that could cause blank/late map initialization - **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles - **Voice Alerts** - Configurable text-to-speech event notifications across modes
- BT Locate trail restore startup latency by batching historical GPS point rendering - **Signal Fingerprinting** - RF device identification and pattern analysis mode
- **RF Heatmap** - Geographic signal density visualization with Leaflet heatmap overlay
--- - **SignalID** - Automatic signal classification via SigIDWiki API integration
- **PWA Support** - Installable web app with service worker caching and manifest
## [2.21.0] - 2026-02-20 - **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames
### Added - **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls
- Analytics panels for operational insights and temporal pattern analysis - App icon (SVG) for PWA and browser tab
### Changed ### Changed
- Global map theme refresh with improved contrast and cross-dashboard consistency - **WebSDR overhaul** - Improved receiver management, audio streaming, and UI
- Cross-app UX refinements for accessibility, mode consistency, and render performance - **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting - **Mode transitions** - Smoother navigation with performance instrumentation
- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing
### Fixed - **Listening Post** - Refactored with cross-module frequency routing
- Weather satellite auto-scheduler and Mercator tracking reliability issues - **SSTV decoder** - State machine improvements and partial image streaming
- Bluetooth/WiFi runtime health issues affecting scanner continuity - Analytics mode removed; per-mode analytics panels integrated into existing dashboards
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
### Fixed
--- - ADS-B SSE multi-client fanout stability and update flush timing
- WiFi scanner robustness and monitor mode teardown reliability
## [2.15.0] - 2026-02-09 - Agent client reliability improvements for remote sensor nodes
- SSTV VIS detector state reporting in signal monitor diagnostics
### Added
### Documentation
- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages
- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file
- Fixed tool name `rtl_amr``rtlamr` throughout all docs
- Fixed incorrect entry point examples (`python app.py``sudo -E venv/bin/python intercept.py`)
- Removed duplicate AIS Vessel Tracking section from FEATURES.md
- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed
- Added ACARS and VDL2 mode descriptions to in-app help modal
- GitHub Pages site: corrected Docker command, license, and tool name references
---
## [2.21.1] - 2026-02-20
### Fixed
- BT Locate map first-load rendering race that could cause blank/late map initialization
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
- BT Locate trail restore startup latency by batching historical GPS point rendering
---
## [2.21.0] - 2026-02-20
### Added
- Analytics panels for operational insights and temporal pattern analysis
### Changed
- Global map theme refresh with improved contrast and cross-dashboard consistency
- Cross-app UX refinements for accessibility, mode consistency, and render performance
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
### Fixed
- Weather satellite auto-scheduler and Mercator tracking reliability issues
- Bluetooth/WiFi runtime health issues affecting scanner continuity
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
---
## [2.15.0] - 2026-02-09
### Added
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT - **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
- Click-to-tune, zoom controls, and auto-scaling quantization - Click-to-tune, zoom controls, and auto-scaling quantization
- Shared waterfall UI across SDR modes with function bar controls - Shared waterfall UI across SDR modes with function bar controls
+11 -1
View File
@@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
soapysdr-module-airspy \ soapysdr-module-airspy \
airspy \ airspy \
limesuite \ limesuite \
hackrf \
# Utilities # Utilities
curl \ curl \
procps \ procps \
@@ -190,6 +189,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fi \ fi \
&& cd /tmp \ && cd /tmp \
&& rm -rf /tmp/SatDump \ && rm -rf /tmp/SatDump \
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
&& cd /tmp \
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
&& cd hackrf/host \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& rm -rf /tmp/hackrf \
# Build rtlamr (utility meter decoder - requires Go) # Build rtlamr (utility meter decoder - requires Go)
&& cd /tmp \ && cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \ && curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
+5 -7
View File
@@ -2,7 +2,7 @@
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+"> <img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License"> <img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform"> <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
</p> </p>
@@ -40,7 +40,7 @@ Support the developer of this open-source project
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF) - **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
- **APRS** - Amateur packet radio position reports and telemetry via direwolf - **APRS** - Amateur packet radio position reports and telemetry via direwolf
- **Satellite Tracking** - Pass prediction with polar plot and ground track map - **Satellite Tracking** - Pass prediction with polar plot and ground track map
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr - **Utility Meters** - Electric, gas, and water meter reading via rtlamr
- **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)
@@ -57,8 +57,6 @@ Support the developer of this open-source project
## Installation / Debian / Ubuntu / MacOS ## Installation / Debian / Ubuntu / MacOS
```
**1. Clone and run:** **1. Clone and run:**
```bash ```bash
git clone https://github.com/smittix/intercept.git git clone https://github.com/smittix/intercept.git
@@ -150,7 +148,7 @@ Set these as environment variables for either local installs or Docker:
```bash ```bash
INTERCEPT_ADSB_AUTO_START=true \ INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \ INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py sudo -E venv/bin/python intercept.py
``` ```
**Docker example (.env)** **Docker example (.env)**
@@ -172,7 +170,7 @@ Then open **/adsb/history** for the reporting dashboard.
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b> After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
--- ---
@@ -245,7 +243,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) | [AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
[acarsdec](https://github.com/TLeconte/acarsdec) | [acarsdec](https://github.com/TLeconte/acarsdec) |
[direwolf](https://github.com/wb2osz/direwolf) | [direwolf](https://github.com/wb2osz/direwolf) |
[rtl_amr](https://github.com/bemasher/rtlamr) | [rtlamr](https://github.com/bemasher/rtlamr) |
[dumpvdl2](https://github.com/szpajder/dumpvdl2) | [dumpvdl2](https://github.com/szpajder/dumpvdl2) |
[aircrack-ng](https://www.aircrack-ng.org/) | [aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) | [Leaflet.js](https://leafletjs.com/) |
+50 -31
View File
@@ -6,35 +6,54 @@ import logging
import os import os
import sys import sys
# Application version # Application version
VERSION = "2.21.1" VERSION = "2.22.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{ {
"version": "2.21.1", "version": "2.22.0",
"date": "February 2026", "date": "February 2026",
"highlights": [ "highlights": [
"BT Locate map first-load fix with render stabilization retries during initial mode open", "Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
"BT Locate trail restore optimization for faster startup when historical GPS points exist", "Voice alerts for configurable event notifications across modes",
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render", "Signal fingerprinting mode for RF device identification and pattern analysis",
] "RF Heatmap for geographic signal density visualization",
}, "SignalID integration via SigIDWiki API for automatic signal classification",
{ "PWA support: installable web app with service worker and manifest",
"version": "2.21.0", "Mode stop responsiveness improvements with faster timeout handling",
"date": "February 2026", "Navigation performance instrumentation and smoother mode transitions",
"highlights": [ "Pager, sensor, and SSTV real-time signal scope visualization",
"Global map theme refresh with improved contrast and cross-dashboard consistency", "ADS-B MSG2 surface movement parsing for ground vehicle tracking",
"Cross-app UX updates for accessibility, mode consistency, and render performance", "WebSDR major overhaul with improved receiver management and audio streaming",
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking", "Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements", "Help modal updated with ACARS and VDL2 mode descriptions",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding", ]
"Analytics enhancements with operational insights and temporal pattern panels", },
] {
}, "version": "2.21.1",
{ "date": "February 2026",
"version": "2.20.0", "highlights": [
"date": "February 2026", "BT Locate map first-load fix with render stabilization retries during initial mode open",
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
]
},
{
"version": "2.21.0",
"date": "February 2026",
"highlights": [
"Global map theme refresh with improved contrast and cross-dashboard consistency",
"Cross-app UX updates for accessibility, mode consistency, and render performance",
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
"Analytics enhancements with operational insights and temporal pattern panels",
]
},
{
"version": "2.20.0",
"date": "February 2026",
"highlights": [ "highlights": [
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL", "Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
"Kp index, solar wind, X-ray flux charts with Chart.js visualization", "Kp index, solar wind, X-ray flux charts with Chart.js visualization",
@@ -99,14 +118,14 @@ CHANGELOG = [
"Pure Python SSTV decoder replacing broken slowrx dependency", "Pure Python SSTV decoder replacing broken slowrx dependency",
"Real-time signal scope for pager, sensor, and SSTV modes", "Real-time signal scope for pager, sensor, and SSTV modes",
"USB-level device probe to prevent cryptic rtl_fm crashes", "USB-level device probe to prevent cryptic rtl_fm crashes",
"SDR device lock-up fix from unreleased device registry on crash", "SDR device lock-up fix from unreleased device registry on crash",
] ]
}, },
{ {
"version": "2.14.0", "version": "2.14.0",
"date": "February 2026", "date": "February 2026",
"highlights": [ "highlights": [
"HF SSTV general mode with predefined shortwave frequencies", "HF SSTV general mode with predefined shortwave frequencies",
"WebSDR integration for remote HF/shortwave listening", "WebSDR integration for remote HF/shortwave listening",
"Listening Post signal scanner and audio pipeline improvements", "Listening Post signal scanner and audio pipeline improvements",
"TSCM sweep resilience, WiFi detection, and correlation fixes", "TSCM sweep resilience, WiFi detection, and correlation fixes",
-11
View File
@@ -24,17 +24,6 @@ Complete feature list for all modules.
- **Wideband spectrum analysis** with real-time visualization - **Wideband spectrum analysis** with real-time visualization
- **I/Q capture** - record raw samples for offline analysis - **I/Q capture** - record raw samples for offline analysis
## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
- **Full-screen dashboard** - dedicated popout with interactive map
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
- **Navigation data** - speed, course, heading, rate of turn
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
- **Vessel dimensions** - length, width, draught
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Spy Stations (Number Stations) ## Spy Stations (Number Stations)
- **Comprehensive database** of active number stations and diplomatic networks - **Comprehensive database** of active number stations and diplomatic networks
+1 -1
View File
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
```bash ```bash
INTERCEPT_ADSB_AUTO_START=true \ INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \ INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py sudo -E venv/bin/python intercept.py
``` ```
**Docker example (.env)** **Docker example (.env)**
+3 -3
View File
@@ -110,7 +110,7 @@
<div class="feature-card" data-category="signals"> <div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div> <div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
<h3>Utility Meters</h3> <h3>Utility Meters</h3>
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p> <p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
</div> </div>
<div class="feature-card" data-category="tracking"> <div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div> <div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
@@ -321,7 +321,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
<div class="code-block"> <div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git <pre><code>git clone https://github.com/smittix/intercept.git
cd intercept cd intercept
docker compose up -d</code></pre> docker compose --profile basic up -d --build</code></pre>
</div> </div>
<p class="install-note">Requires privileged mode for USB SDR access</p> <p class="install-note">Requires privileged mode for USB SDR access</p>
</div> </div>
@@ -422,7 +422,7 @@ docker compose up -d</code></pre>
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p> <p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
<p class="disclaimer">For educational and authorized testing purposes only.</p> <p class="disclaimer">For educational and authorized testing purposes only.</p>
</div> </div>
</div> </div>
+66
View File
@@ -560,3 +560,69 @@
font-size: 9px; font-size: 9px;
} }
} }
/* ── Crosshair sweep animation ───────────────────────────────────── */
.btl-crosshair-overlay {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 1200;
--btl-crosshair-x-start: 100%;
--btl-crosshair-y-start: 100%;
--btl-crosshair-x-end: 50%;
--btl-crosshair-y-end: 50%;
--btl-crosshair-duration: 1500ms;
}
.btl-crosshair-line {
position: absolute;
opacity: 0;
background: var(--accent-cyan);
will-change: transform, opacity;
}
.btl-crosshair-vertical {
top: 0;
bottom: 0;
width: 1px;
left: 0;
transform: translateX(var(--btl-crosshair-x-start));
}
.btl-crosshair-horizontal {
left: 0;
right: 0;
height: 1px;
top: 0;
transform: translateY(var(--btl-crosshair-y-start));
}
.btl-crosshair-overlay.active .btl-crosshair-vertical {
animation: btlCrosshairSweepX var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
}
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
animation: btlCrosshairSweepY var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
}
@keyframes btlCrosshairSweepX {
0% { transform: translateX(var(--btl-crosshair-x-start)); opacity: 0; }
12% { opacity: 1; }
85% { opacity: 1; }
100% { transform: translateX(var(--btl-crosshair-x-end)); opacity: 0; }
}
@keyframes btlCrosshairSweepY {
0% { transform: translateY(var(--btl-crosshair-y-start)); opacity: 0; }
12% { opacity: 1; }
85% { opacity: 1; }
100% { transform: translateY(var(--btl-crosshair-y-end)); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btl-crosshair-overlay.active .btl-crosshair-vertical,
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
animation-duration: 220ms;
}
}
+9 -9
View File
@@ -8,17 +8,17 @@ const KeyboardShortcuts = (function () {
function _handle(e) { function _handle(e) {
if (e.target.matches(GUARD_SELECTOR)) return; if (e.target.matches(GUARD_SELECTOR)) return;
if (e.altKey) { if (e.altKey) {
switch (e.key.toLowerCase()) { switch (e.code) {
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break; case 'KeyW': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break; case 'KeyM': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
case 's': e.preventDefault(); _toggleSidebar(); break; case 'KeyS': e.preventDefault(); _toggleSidebar(); break;
case 'k': e.preventDefault(); showHelp(); break; case 'KeyK': e.preventDefault(); showHelp(); break;
case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break; case 'KeyC': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
default: default:
if (e.key >= '1' && e.key <= '9') { if (e.code >= 'Digit1' && e.code <= 'Digit9') {
e.preventDefault(); e.preventDefault();
_switchToNthMode(parseInt(e.key) - 1); _switchToNthMode(parseInt(e.code.replace('Digit', '')) - 1);
} }
} }
} else if (!e.ctrlKey && !e.metaKey) { } else if (!e.ctrlKey && !e.metaKey) {
+39 -12
View File
@@ -44,6 +44,7 @@ const BtLocate = (function() {
let queuedDetectionTimer = null; let queuedDetectionTimer = null;
let lastDetectionRenderAt = 0; let lastDetectionRenderAt = 0;
let startRequestInFlight = false; let startRequestInFlight = false;
let crosshairResetTimer = null;
const MAX_HEAT_POINTS = 1200; const MAX_HEAT_POINTS = 1200;
const MAX_TRAIL_POINTS = 1200; const MAX_TRAIL_POINTS = 1200;
@@ -349,19 +350,18 @@ const BtLocate = (function() {
} }
function stop() { function stop() {
// Update UI immediately — don't wait for the backend response.
if (queuedDetectionTimer) {
clearTimeout(queuedDetectionTimer);
queuedDetectionTimer = null;
}
queuedDetection = null;
queuedDetectionOptions = null;
showIdleUI();
disconnectSSE();
stopAudio();
// Notify backend asynchronously.
fetch('/bt_locate/stop', { method: 'POST' }) fetch('/bt_locate/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
if (queuedDetectionTimer) {
clearTimeout(queuedDetectionTimer);
queuedDetectionTimer = null;
}
queuedDetection = null;
queuedDetectionOptions = null;
showIdleUI();
disconnectSSE();
stopAudio();
})
.catch(err => console.error('[BtLocate] Stop error:', err)); .catch(err => console.error('[BtLocate] Stop error:', err));
} }
@@ -703,6 +703,32 @@ const BtLocate = (function() {
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0; if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
} }
function triggerCrosshairAnimation(lat, lon) {
if (!map) return;
const overlay = document.getElementById('btLocateCrosshairOverlay');
if (!overlay) return;
const size = map.getSize();
const point = map.latLngToContainerPoint([lat, lon]);
const targetX = Math.max(0, Math.min(size.x, point.x));
const targetY = Math.max(0, Math.min(size.y, point.y));
const startX = size.x + 8;
const startY = size.y + 8;
const duration = 1500;
overlay.style.setProperty('--btl-crosshair-x-start', `${startX}px`);
overlay.style.setProperty('--btl-crosshair-y-start', `${startY}px`);
overlay.style.setProperty('--btl-crosshair-x-end', `${targetX}px`);
overlay.style.setProperty('--btl-crosshair-y-end', `${targetY}px`);
overlay.style.setProperty('--btl-crosshair-duration', `${duration}ms`);
overlay.classList.remove('active');
void overlay.offsetWidth;
overlay.classList.add('active');
if (crosshairResetTimer) clearTimeout(crosshairResetTimer);
crosshairResetTimer = setTimeout(() => {
overlay.classList.remove('active');
crosshairResetTimer = null;
}, duration + 100);
}
function addMapMarker(point, options = {}) { function addMapMarker(point, options = {}) {
if (!map || point.lat == null || point.lon == null) return false; if (!map || point.lat == null || point.lon == null) return false;
const lat = Number(point.lat); const lat = Number(point.lat);
@@ -737,6 +763,7 @@ const BtLocate = (function() {
'Time: ' + formatPointTimestamp(trailPoint.timestamp) + 'Time: ' + formatPointTimestamp(trailPoint.timestamp) +
'</div>' '</div>'
); );
marker.on('click', () => triggerCrosshairAnimation(lat, lon));
trailPoints.push(trailPoint); trailPoints.push(trailPoint);
mapMarkers.push(marker); mapMarkers.push(marker);
+4
View File
@@ -1831,6 +1831,10 @@
</div> </div>
<div class="btl-map-container" style="flex: 1; min-height: 250px; position: relative; overflow: hidden;"> <div class="btl-map-container" style="flex: 1; min-height: 250px; position: relative; overflow: hidden;">
<div id="btLocateMap" style="position: absolute; inset: 0;"></div> <div id="btLocateMap" style="position: absolute; inset: 0;"></div>
<div id="btLocateCrosshairOverlay" class="btl-crosshair-overlay" aria-hidden="true">
<div class="btl-crosshair-line btl-crosshair-vertical"></div>
<div class="btl-crosshair-line btl-crosshair-horizontal"></div>
</div>
<div class="btl-map-overlay-controls"> <div class="btl-map-overlay-controls">
<label class="btl-map-overlay-toggle"> <label class="btl-map-overlay-toggle">
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()"> <input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
+103 -85
View File
@@ -4,20 +4,20 @@
#} #}
<!-- Help Modal --> <!-- Help Modal -->
<div id="helpModal" class="help-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="helpModalTitle" onclick="if(event.target === this) hideHelp()"> <div id="helpModal" class="help-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="helpModalTitle" onclick="if(event.target === this) hideHelp()">
<div class="help-content" tabindex="-1"> <div class="help-content" tabindex="-1">
<button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">&times;</button> <button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">&times;</button>
<h2 id="helpModalTitle">iNTERCEPT Help</h2> <h2 id="helpModalTitle">iNTERCEPT Help</h2>
<div class="help-tabs" role="tablist" aria-label="Help sections"> <div class="help-tabs" role="tablist" aria-label="Help sections">
<button type="button" class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')" role="tab" aria-controls="help-icons" aria-selected="true">Icons</button> <button type="button" class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')" role="tab" aria-controls="help-icons" aria-selected="true">Icons</button>
<button type="button" class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')" role="tab" aria-controls="help-modes" aria-selected="false">Modes</button> <button type="button" class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')" role="tab" aria-controls="help-modes" aria-selected="false">Modes</button>
<button type="button" class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')" role="tab" aria-controls="help-wifi" aria-selected="false">WiFi</button> <button type="button" class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')" role="tab" aria-controls="help-wifi" aria-selected="false">WiFi</button>
<button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button> <button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button>
</div> </div>
<!-- Icons Section --> <!-- Icons Section -->
<div id="help-icons" class="help-section active" role="tabpanel"> <div id="help-icons" class="help-section active" role="tabpanel">
<h3>Stats Bar Icons</h3> <h3>Stats Bar Icons</h3>
<div class="icon-grid"> <div class="icon-grid">
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div>
@@ -43,7 +43,7 @@
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span><span class="desc">APRS - Amateur radio tracking</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span><span class="desc">APRS - Amateur radio tracking</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg></span><span class="desc">Waterfall - SDR receiver + signal ID</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg></span><span class="desc">Waterfall - SDR receiver + signal ID</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span><span class="desc">Spy Stations - Number stations database</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span><span class="desc">Spy Stations - Number stations database</span></div>
<div class="icon-item"><span class="icon icon--sm"><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"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div> <div class="icon-item"><span class="icon icon--sm"><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"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
<div class="icon-item"><span class="icon icon--sm"><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"/><line x1="2" y1="12" x2="22" y2="12"/><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></span><span class="desc">WebSDR - Remote SDR receivers</span></div> <div class="icon-item"><span class="icon icon--sm"><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"/><line x1="2" y1="12" x2="22" y2="12"/><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></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
@@ -62,7 +62,7 @@
</div> </div>
<!-- Modes Section --> <!-- Modes Section -->
<div id="help-modes" class="help-section" role="tabpanel" hidden> <div id="help-modes" class="help-section" role="tabpanel" hidden>
<h3>Pager Mode</h3> <h3>Pager Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li> <li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
@@ -114,14 +114,14 @@
<li>Interactive map shows station positions in real-time</li> <li>Interactive map shows station positions in real-time</li>
</ul> </ul>
<h3>Spectrum Waterfall Mode</h3> <h3>Spectrum Waterfall Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li>Wideband SDR scanner with spectrum visualization</li> <li>Wideband SDR scanner with spectrum visualization</li>
<li>Tune to any frequency supported by your SDR hardware</li> <li>Tune to any frequency supported by your SDR hardware</li>
<li>AM/FM/USB/LSB demodulation modes</li> <li>AM/FM/USB/LSB demodulation modes</li>
<li>Bookmark frequencies for quick recall</li> <li>Bookmark frequencies for quick recall</li>
<li>Quick tune presets for emergency and marine channels</li> <li>Quick tune presets for emergency and marine channels</li>
</ul> </ul>
<h3>Spy Stations</h3> <h3>Spy Stations</h3>
<ul class="tip-list"> <ul class="tip-list">
@@ -129,7 +129,7 @@
<li>Browse stations from priyom.org with frequencies and schedules</li> <li>Browse stations from priyom.org with frequencies and schedules</li>
<li>Filter by type (number/diplomatic), country, and mode</li> <li>Filter by type (number/diplomatic), country, and mode</li>
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li> <li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
<li>Click "Tune" to listen via Spectrum Waterfall mode</li> <li>Click "Tune" to listen via Spectrum Waterfall mode</li>
</ul> </ul>
<h3>Meshtastic Mode</h3> <h3>Meshtastic Mode</h3>
@@ -166,11 +166,27 @@
<li>View next pass predictions with elevation and duration</li> <li>View next pass predictions with elevation and duration</li>
</ul> </ul>
<h3>ACARS Mode</h3>
<ul class="tip-list">
<li>Decodes Aircraft Communications Addressing and Reporting System messages via acarsdec</li>
<li>Receives operational, weather, and position reports on 129-136 MHz</li>
<li>Supports North America, Europe, and Asia-Pacific regional frequency presets</li>
<li>Filter by message type, flight ID, or aircraft registration</li>
</ul>
<h3>VDL2 Mode</h3>
<ul class="tip-list">
<li>Decodes VHF Data Link Mode 2 aircraft datalink messages via dumpvdl2</li>
<li>Captures ACARS-over-AVLC frames with full signal analysis (SNR, burst length)</li>
<li>Monitor multiple VDL2 frequencies simultaneously (136.725, 136.775, 136.975 MHz)</li>
<li>Export captured messages to CSV or JSON for offline analysis</li>
</ul>
<h3>ISS SSTV Mode</h3> <h3>ISS SSTV Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li> <li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li> <li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
<li>Images decoded in real-time using slowrx</li> <li>Images decoded in real-time using the built-in pure Python decoder</li>
<li>Gallery view with timestamped decoded images</li> <li>Gallery view with timestamped decoded images</li>
</ul> </ul>
@@ -254,7 +270,7 @@
</div> </div>
<!-- WiFi Section --> <!-- WiFi Section -->
<div id="help-wifi" class="help-section" role="tabpanel" hidden> <div id="help-wifi" class="help-section" role="tabpanel" hidden>
<h3>Monitor Mode</h3> <h3>Monitor Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li> <li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
@@ -302,7 +318,7 @@
</div> </div>
<!-- Tips Section --> <!-- Tips Section -->
<div id="help-tips" class="help-section" role="tabpanel" hidden> <div id="help-tips" class="help-section" role="tabpanel" hidden>
<h3>General Tips</h3> <h3>General Tips</h3>
<ul class="tip-list"> <ul class="tip-list">
<li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li> <li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li>
@@ -330,15 +346,17 @@
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li> <li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li> <li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li> <li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
<li><strong>Spectrum Waterfall:</strong> RTL-SDR or SoapySDR-compatible hardware</li> <li><strong>Spectrum Waterfall:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li> <li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li> <li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li> <li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li> <li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li> <li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
<li><strong>ISS SSTV:</strong> RTL-SDR, slowrx</li> <li><strong>ACARS:</strong> RTL-SDR, acarsdec</li>
<li><strong>VDL2:</strong> RTL-SDR, dumpvdl2</li>
<li><strong>ISS SSTV:</strong> RTL-SDR (pure Python decoder — no external tools needed)</li>
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li> <li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li> <li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware (pure Python decoder)</li>
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li> <li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li> <li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li> <li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
@@ -360,62 +378,62 @@
<script> <script>
// Help modal functions - defined here so all pages have them // Help modal functions - defined here so all pages have them
(function() { (function() {
let lastHelpFocusEl = null; let lastHelpFocusEl = null;
// Only define if not already defined (index.html defines its own) // Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') { if (typeof window.showHelp === 'undefined') {
window.showHelp = function() { window.showHelp = function() {
const modal = document.getElementById('helpModal'); const modal = document.getElementById('helpModal');
lastHelpFocusEl = document.activeElement; lastHelpFocusEl = document.activeElement;
modal.classList.add('active'); modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false'); modal.setAttribute('aria-hidden', 'false');
const content = modal.querySelector('.help-content'); const content = modal.querySelector('.help-content');
if (content) content.focus(); if (content) content.focus();
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
}; };
} }
if (typeof window.hideHelp === 'undefined') { if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() { window.hideHelp = function() {
const modal = document.getElementById('helpModal'); const modal = document.getElementById('helpModal');
modal.classList.remove('active'); modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true'); modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = ''; document.body.style.overflow = '';
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') { if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
lastHelpFocusEl.focus(); lastHelpFocusEl.focus();
} }
}; };
} }
if (typeof window.switchHelpTab === 'undefined') { if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(tab) { window.switchHelpTab = function(tab) {
document.querySelectorAll('.help-tab').forEach(t => { document.querySelectorAll('.help-tab').forEach(t => {
const isActive = t.dataset.tab === tab; const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive); t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive ? 'true' : 'false'); t.setAttribute('aria-selected', isActive ? 'true' : 'false');
}); });
document.querySelectorAll('.help-section').forEach(s => { document.querySelectorAll('.help-section').forEach(s => {
const isActive = s.id === ('help-' + tab); const isActive = s.id === ('help-' + tab);
s.classList.toggle('active', isActive); s.classList.toggle('active', isActive);
s.hidden = !isActive; s.hidden = !isActive;
}); });
}; };
} }
// Keyboard shortcuts for help (only add once) // Keyboard shortcuts for help (only add once)
if (!window._helpKeyboardSetup) { if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true; window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
const modal = document.getElementById('helpModal'); const modal = document.getElementById('helpModal');
if (modal && modal.classList.contains('active')) hideHelp(); if (modal && modal.classList.contains('active')) hideHelp();
} }
// Open help with F1 or ? key (when not typing in an input) // Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal'); var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) { if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
e.preventDefault(); e.preventDefault();
showHelp(); showHelp();
} }
}); });
} }
+3
View File
@@ -179,6 +179,9 @@
<button type="button" class="nav-tool-btn" id="voiceMuteBtn" onclick="window.VoiceAlerts && VoiceAlerts.toggleMute()" title="Toggle voice alerts" aria-label="Toggle voice alerts"> <button type="button" class="nav-tool-btn" id="voiceMuteBtn" onclick="window.VoiceAlerts && VoiceAlerts.toggleMute()" title="Toggle voice alerts" aria-label="Toggle voice alerts">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span> <span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>
</button> </button>
<button type="button" class="nav-tool-btn" onclick="window.CheatSheets && CheatSheets.showForCurrentMode()" title="Mode cheat sheet (Alt+C)" aria-label="Mode cheat sheet">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
</button>
<button type="button" class="nav-tool-btn" onclick="window.KeyboardShortcuts && KeyboardShortcuts.showHelp()" title="Keyboard shortcuts (Alt+K)" aria-label="Keyboard shortcuts"> <button type="button" class="nav-tool-btn" onclick="window.KeyboardShortcuts && KeyboardShortcuts.showHelp()" title="Keyboard shortcuts (Alt+K)" aria-label="Keyboard shortcuts">
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg></span> <span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg></span>
</button> </button>
+6
View File
@@ -55,6 +55,12 @@ def _load_meta() -> dict[str, Any] | None:
if os.path.exists(DB_META_FILE): if os.path.exists(DB_META_FILE):
with open(DB_META_FILE, 'r') as f: with open(DB_META_FILE, 'r') as f:
return json.load(f) return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Corrupt aircraft db meta file, removing: {e}")
try:
os.remove(DB_META_FILE)
except OSError:
pass
except Exception as e: except Exception as e:
logger.warning(f"Error loading aircraft db meta: {e}") logger.warning(f"Error loading aircraft db meta: {e}")
return None return None
+275 -257
View File
@@ -7,68 +7,68 @@ distance estimation, and proximity alerts for search and rescue operations.
from __future__ import annotations from __future__ import annotations
import logging import logging
import queue import queue
import threading import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from utils.bluetooth.models import BTDeviceAggregate from utils.bluetooth.models import BTDeviceAggregate
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
from utils.gps import get_current_position from utils.gps import get_current_position
logger = logging.getLogger('intercept.bt_locate') logger = logging.getLogger('intercept.bt_locate')
# Maximum trail points to retain # Maximum trail points to retain
MAX_TRAIL_POINTS = 500 MAX_TRAIL_POINTS = 500
# EMA smoothing factor for RSSI # EMA smoothing factor for RSSI
EMA_ALPHA = 0.3 EMA_ALPHA = 0.3
# Polling/restart tuning for scanner resilience without high CPU churn. # Polling/restart tuning for scanner resilience without high CPU churn.
POLL_INTERVAL_SECONDS = 1.5 POLL_INTERVAL_SECONDS = 1.5
SCAN_RESTART_BACKOFF_SECONDS = 8.0 SCAN_RESTART_BACKOFF_SECONDS = 8.0
NO_MATCH_LOG_EVERY_POLLS = 10 NO_MATCH_LOG_EVERY_POLLS = 10
def _normalize_mac(address: str | None) -> str | None: def _normalize_mac(address: str | None) -> str | None:
"""Normalize MAC string to colon-separated uppercase form when possible.""" """Normalize MAC string to colon-separated uppercase form when possible."""
if not address: if not address:
return None return None
text = str(address).strip().upper().replace('-', ':') text = str(address).strip().upper().replace('-', ':')
if not text: if not text:
return None return None
# Handle raw 12-hex form: AABBCCDDEEFF # Handle raw 12-hex form: AABBCCDDEEFF
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF') raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
if ':' not in text and len(raw) == 12: if ':' not in text and len(raw) == 12:
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2)) text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
parts = text.split(':') parts = text.split(':')
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts): if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts):
return ':'.join(parts) return ':'.join(parts)
# Return cleaned original when not a strict MAC (caller may still use exact matching) # Return cleaned original when not a strict MAC (caller may still use exact matching)
return text return text
def _address_looks_like_rpa(address: str | None) -> bool: def _address_looks_like_rpa(address: str | None) -> bool:
""" """
Return True when an address looks like a Resolvable Private Address. Return True when an address looks like a Resolvable Private Address.
RPA check: most-significant two bits of the first octet are `01`. RPA check: most-significant two bits of the first octet are `01`.
""" """
normalized = _normalize_mac(address) normalized = _normalize_mac(address)
if not normalized: if not normalized:
return False return False
try: try:
first_octet = int(normalized.split(':', 1)[0], 16) first_octet = int(normalized.split(':', 1)[0], 16)
except (ValueError, TypeError): except (ValueError, TypeError):
return False return False
return (first_octet >> 6) == 1 return (first_octet >> 6) == 1
class Environment(Enum): class Environment(Enum):
@@ -125,110 +125,110 @@ def resolve_rpa(irk: bytes, address: str) -> bool:
return computed_hash == expected_hash return computed_hash == expected_hash
@dataclass @dataclass
class LocateTarget: class LocateTarget:
"""Target device specification for locate session.""" """Target device specification for locate session."""
mac_address: str | None = None mac_address: str | None = None
name_pattern: str | None = None name_pattern: str | None = None
irk_hex: str | None = None irk_hex: str | None = None
device_id: str | None = None device_id: str | None = None
device_key: str | None = None device_key: str | None = None
fingerprint_id: str | None = None fingerprint_id: str | None = None
# Hand-off metadata from Bluetooth mode # Hand-off metadata from Bluetooth mode
known_name: str | None = None known_name: str | None = None
known_manufacturer: str | None = None known_manufacturer: str | None = None
last_known_rssi: int | None = None last_known_rssi: int | None = None
_cached_irk_hex: str | None = field(default=None, init=False, repr=False) _cached_irk_hex: str | None = field(default=None, init=False, repr=False)
_cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False) _cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False)
def _get_irk_bytes(self) -> bytes | None: def _get_irk_bytes(self) -> bytes | None:
"""Parse/cache target IRK bytes once for repeated match checks.""" """Parse/cache target IRK bytes once for repeated match checks."""
if not self.irk_hex: if not self.irk_hex:
return None return None
if self._cached_irk_hex == self.irk_hex: if self._cached_irk_hex == self.irk_hex:
return self._cached_irk_bytes return self._cached_irk_bytes
self._cached_irk_hex = self.irk_hex self._cached_irk_hex = self.irk_hex
self._cached_irk_bytes = None self._cached_irk_bytes = None
try: try:
parsed = bytes.fromhex(self.irk_hex) parsed = bytes.fromhex(self.irk_hex)
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
if len(parsed) != 16: if len(parsed) != 16:
return None return None
self._cached_irk_bytes = parsed self._cached_irk_bytes = parsed
return parsed return parsed
def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool: def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool:
"""Check if a device matches this target.""" """Check if a device matches this target."""
# Match by stable device key (survives MAC randomization for many devices) # Match by stable device key (survives MAC randomization for many devices)
if self.device_key and getattr(device, 'device_key', None) == self.device_key: if self.device_key and getattr(device, 'device_key', None) == self.device_key:
return True return True
# Match by device_id (exact) # Match by device_id (exact)
if self.device_id and device.device_id == self.device_id: if self.device_id and device.device_id == self.device_id:
return True return True
# Match by device_id address portion (without :address_type suffix) # Match by device_id address portion (without :address_type suffix)
if self.device_id and ':' in self.device_id: if self.device_id and ':' in self.device_id:
target_addr_part = self.device_id.rsplit(':', 1)[0].upper() target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
dev_addr = (device.address or '').upper() dev_addr = (device.address or '').upper()
if target_addr_part and dev_addr == target_addr_part: if target_addr_part and dev_addr == target_addr_part:
return True return True
# Match by MAC/address (case-insensitive, normalize separators) # Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address: if self.mac_address:
dev_addr = _normalize_mac(device.address) dev_addr = _normalize_mac(device.address)
target_addr = _normalize_mac(self.mac_address) target_addr = _normalize_mac(self.mac_address)
if dev_addr and target_addr and dev_addr == target_addr: if dev_addr and target_addr and dev_addr == target_addr:
return True return True
# Match by payload fingerprint.
# For explicit hand-off sessions, allow exact fingerprint matches even if
# stability is still warming up.
if self.fingerprint_id:
dev_fp = getattr(device, 'payload_fingerprint_id', None)
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
if dev_fp and dev_fp == self.fingerprint_id:
if dev_fp_stability >= 0.35:
return True
if any([self.device_id, self.device_key, self.mac_address, self.known_name]):
return True
# Match by RPA resolution
if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
irk = irk_bytes or self._get_irk_bytes()
if irk and resolve_rpa(irk, device.address):
return True
# Match by name pattern # Match by payload fingerprint.
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): # For explicit hand-off sessions, allow exact fingerprint matches even if
return True # stability is still warming up.
if self.fingerprint_id:
# Match by known_name from handoff (exact or loose normalized match) dev_fp = getattr(device, 'payload_fingerprint_id', None)
if self.known_name and device.name: dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
target_name = self.known_name.strip().lower() if dev_fp and dev_fp == self.fingerprint_id:
device_name = device.name.strip().lower() if dev_fp_stability >= 0.35:
if target_name and ( return True
target_name == device_name if any([self.device_id, self.device_key, self.mac_address, self.known_name]):
or target_name in device_name return True
or device_name in target_name
): # Match by RPA resolution
return True if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
irk = irk_bytes or self._get_irk_bytes()
return False if irk and resolve_rpa(irk, device.address):
return True
def to_dict(self) -> dict:
return { # Match by name pattern
'mac_address': self.mac_address, if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
'name_pattern': self.name_pattern, return True
'irk_hex': self.irk_hex,
'device_id': self.device_id, # Match by known_name from handoff (exact or loose normalized match)
'device_key': self.device_key, if self.known_name and device.name:
'fingerprint_id': self.fingerprint_id, target_name = self.known_name.strip().lower()
'known_name': self.known_name, device_name = device.name.strip().lower()
'known_manufacturer': self.known_manufacturer, if target_name and (
'last_known_rssi': self.last_known_rssi, target_name == device_name
} or target_name in device_name
or device_name in target_name
):
return True
return False
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,
'device_key': self.device_key,
'fingerprint_id': self.fingerprint_id,
'known_name': self.known_name,
'known_manufacturer': self.known_manufacturer,
'last_known_rssi': self.last_known_rssi,
}
class DistanceEstimator: class DistanceEstimator:
@@ -300,7 +300,7 @@ class LocateSession:
self.environment = environment self.environment = environment
self.fallback_lat = fallback_lat self.fallback_lat = fallback_lat
self.fallback_lon = fallback_lon self.fallback_lon = fallback_lon
self._lock = threading.Lock() self._lock = threading.Lock()
# Distance estimator # Distance estimator
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
@@ -324,9 +324,9 @@ class LocateSession:
# Debug counters # Debug counters
self.callback_call_count = 0 self.callback_call_count = 0
self.poll_count = 0 self.poll_count = 0
self._last_seen_device: str | None = None self._last_seen_device: str | None = None
self._last_scan_restart_attempt = 0.0 self._last_scan_restart_attempt = 0.0
self._target_irk = target._get_irk_bytes() self._target_irk = target._get_irk_bytes()
# Scanner reference # Scanner reference
self._scanner: BluetoothScanner | None = None self._scanner: BluetoothScanner | None = None
@@ -335,34 +335,34 @@ class LocateSession:
# Track last RSSI per device to detect changes # Track last RSSI per device to detect changes
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
def start(self) -> bool: def start(self) -> bool:
"""Start the locate session. """Start the locate session.
Subscribes to scanner callbacks AND runs a polling thread that Subscribes to scanner callbacks AND runs a polling thread that
checks the aggregator directly (handles bleak scan timeout). checks the aggregator directly (handles bleak scan timeout).
""" """
self._scanner = get_bluetooth_scanner() self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device) self._scanner.add_device_callback(self._on_device)
self._scanner_started_by_us = False self._scanner_started_by_us = False
# Ensure BLE scanning is active # Ensure BLE scanning is active
if not self._scanner.is_scanning: if not self._scanner.is_scanning:
logger.info("BT scanner not running, starting scan for locate session") logger.info("BT scanner not running, starting scan for locate session")
self._scanner_started_by_us = True self._scanner_started_by_us = True
self._last_scan_restart_attempt = time.monotonic() self._last_scan_restart_attempt = time.monotonic()
if not self._scanner.start_scan(mode='auto'): if not self._scanner.start_scan(mode='auto'):
# Surface startup failure to caller and avoid leaving stale callbacks. # Surface startup failure to caller and avoid leaving stale callbacks.
status = self._scanner.get_status() status = self._scanner.get_status()
reason = status.error or "unknown error" reason = status.error or "unknown error"
logger.warning(f"Failed to start BT scanner for locate session: {reason}") logger.warning(f"Failed to start BT scanner for locate session: {reason}")
self._scanner.remove_device_callback(self._on_device) self._scanner.remove_device_callback(self._on_device)
self._scanner = None self._scanner = None
self._scanner_started_by_us = False self._scanner_started_by_us = False
return False return False
self.active = True self.active = True
self.started_at = datetime.now() self.started_at = datetime.now()
self._stop_event.clear() self._stop_event.clear()
# Start polling thread as reliable fallback # Start polling thread as reliable fallback
self._poll_thread = threading.Thread( self._poll_thread = threading.Thread(
@@ -388,40 +388,40 @@ class LocateSession:
def _poll_loop(self) -> None: def _poll_loop(self) -> None:
"""Poll scanner aggregator for target device updates.""" """Poll scanner aggregator for target device updates."""
while not self._stop_event.is_set(): while not self._stop_event.is_set():
self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS) self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
if self._stop_event.is_set(): if self._stop_event.is_set():
break break
try: try:
self._check_aggregator() self._check_aggregator()
except Exception as e: except Exception as e:
logger.error(f"Locate poll error: {e}") logger.error(f"Locate poll error: {e}")
def _check_aggregator(self) -> None: def _check_aggregator(self) -> None:
"""Check the scanner's aggregator for the target device.""" """Check the scanner's aggregator for the target device."""
if not self._scanner: if not self._scanner:
return return
self.poll_count += 1 self.poll_count += 1
# Restart scan if it expired (bleak 10s timeout) # Restart scan if it expired (bleak 10s timeout)
if not self._scanner.is_scanning: if not self._scanner.is_scanning:
now = time.monotonic() now = time.monotonic()
if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS: if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS:
self._last_scan_restart_attempt = now self._last_scan_restart_attempt = now
logger.info("Scanner stopped, restarting for locate session") logger.info("Scanner stopped, restarting for locate session")
self._scanner.start_scan(mode='auto') self._scanner.start_scan(mode='auto')
# Check devices seen within a recent window. Using a short window # Check devices seen within a recent window. Using a short window
# (rather than the aggregator's full 120s) so that once a device # (rather than the aggregator's full 120s) so that once a device
# goes silent its stale RSSI stops producing detections. The window # goes silent its stale RSSI stops producing detections. The window
# must survive bleak's 10s scan cycle + restart gap (~3s). # must survive bleak's 10s scan cycle + restart gap (~3s).
devices = self._scanner.get_devices(max_age_seconds=15) devices = self._scanner.get_devices(max_age_seconds=15)
found_target = False found_target = False
for device in devices: for device in devices:
if not self.target.matches(device, irk_bytes=self._target_irk): if not self.target.matches(device, irk_bytes=self._target_irk):
continue continue
found_target = True found_target = True
rssi = device.rssi_current rssi = device.rssi_current
if rssi is None: if rssi is None:
continue continue
@@ -429,14 +429,14 @@ class LocateSession:
break # One match per poll cycle is sufficient break # One match per poll cycle is sufficient
# Log periodically for debugging # Log periodically for debugging
if ( if (
self.poll_count <= 5 self.poll_count <= 5
or self.poll_count % 20 == 0 or self.poll_count % 20 == 0
or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0) or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0)
): ):
logger.info( logger.info(
f"Poll #{self.poll_count}: {len(devices)} devices, " f"Poll #{self.poll_count}: {len(devices)} devices, "
f"target_found={found_target}, " f"target_found={found_target}, "
f"detections={self.detection_count}, " f"detections={self.detection_count}, "
f"scanning={self._scanner.is_scanning}" f"scanning={self._scanner.is_scanning}"
) )
@@ -449,8 +449,8 @@ class LocateSession:
self.callback_call_count += 1 self.callback_call_count += 1
self._last_seen_device = f"{device.device_id}|{device.name}" self._last_seen_device = f"{device.device_id}|{device.name}"
if not self.target.matches(device, irk_bytes=self._target_irk): if not self.target.matches(device, irk_bytes=self._target_irk):
return return
rssi = device.rssi_current rssi = device.rssi_current
if rssi is None: if rssi is None:
@@ -478,9 +478,9 @@ class LocateSession:
band = DistanceEstimator.proximity_band(distance) band = DistanceEstimator.proximity_band(distance)
# Check RPA resolution # Check RPA resolution
rpa_resolved = False rpa_resolved = False
if self._target_irk and device.address and _address_looks_like_rpa(device.address): if self._target_irk and device.address and _address_looks_like_rpa(device.address):
rpa_resolved = resolve_rpa(self._target_irk, device.address) rpa_resolved = resolve_rpa(self._target_irk, device.address)
# GPS tag — prefer live GPS, fall back to user-set coordinates # GPS tag — prefer live GPS, fall back to user-set coordinates
gps_pos = get_current_position() gps_pos = get_current_position()
@@ -542,15 +542,15 @@ class LocateSession:
with self._lock: with self._lock:
return [p.to_dict() for p in self.trail if p.lat is not None] return [p.to_dict() for p in self.trail if p.lat is not None]
def get_status(self, include_debug: bool = False) -> dict: def get_status(self, include_debug: bool = False) -> dict:
"""Get session status.""" """Get session status."""
gps_pos = get_current_position() gps_pos = get_current_position()
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA # Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
# deadlock: get_status would hold self._lock then wait on # deadlock: get_status would hold self._lock then wait on
# aggregator._lock, while _poll_loop holds aggregator._lock then # aggregator._lock, while _poll_loop holds aggregator._lock then
# waits on self._lock in _record_detection. # waits on self._lock in _record_detection.
debug_devices = self._debug_device_sample() if include_debug else [] debug_devices = self._debug_device_sample() if include_debug else []
scanner_running = self._scanner.is_scanning if self._scanner else False scanner_running = self._scanner.is_scanning if self._scanner else False
scanner_device_count = self._scanner.device_count if self._scanner else 0 scanner_device_count = self._scanner.device_count if self._scanner else 0
callback_registered = ( callback_registered = (
@@ -586,8 +586,8 @@ class LocateSession:
'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) 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_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, 'latest_band': self.trail[-1].proximity_band if self.trail else None,
'debug_devices': debug_devices, 'debug_devices': debug_devices,
} }
def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None: def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None:
"""Update the environment and recalculate distance estimator.""" """Update the environment and recalculate distance estimator."""
@@ -602,16 +602,16 @@ class LocateSession:
return [] return []
try: try:
devices = self._scanner.get_devices(max_age_seconds=30) devices = self._scanner.get_devices(max_age_seconds=30)
return [ return [
{ {
'id': d.device_id, 'id': d.device_id,
'addr': d.address, 'addr': d.address,
'name': d.name, 'name': d.name,
'rssi': d.rssi_current, 'rssi': d.rssi_current,
'match': self.target.matches(d, irk_bytes=self._target_irk), 'match': self.target.matches(d, irk_bytes=self._target_irk),
} }
for d in devices[:8] for d in devices[:8]
] ]
except Exception: except Exception:
return [] return []
@@ -627,37 +627,55 @@ _session: LocateSession | None = None
_session_lock = threading.Lock() _session_lock = threading.Lock()
def start_locate_session( def start_locate_session(
target: LocateTarget, target: LocateTarget,
environment: Environment = Environment.OUTDOOR, environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None, custom_exponent: float | None = None,
fallback_lat: float | None = None, fallback_lat: float | None = None,
fallback_lon: float | None = None, fallback_lon: float | None = None,
) -> LocateSession: ) -> LocateSession:
"""Start a new locate session, stopping any existing one.""" """Start a new locate session, stopping any existing one."""
global _session global _session
with _session_lock: # Grab and evict any existing session without holding the lock during stop()
if _session and _session.active: # (stop() joins a thread which can block for up to 3 s).
_session.stop() old_session = None
with _session_lock:
_session = LocateSession( if _session and _session.active:
target, environment, custom_exponent, fallback_lat, fallback_lon old_session = _session
) _session = None
if not _session.start():
_session = None if old_session:
raise RuntimeError("Bluetooth scanner failed to start") old_session.stop()
return _session
new_session = LocateSession(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
with _session_lock:
_session = new_session
if not new_session.start():
with _session_lock:
if _session is new_session:
_session = None
raise RuntimeError("Bluetooth scanner failed to start")
return new_session
def stop_locate_session() -> None: def stop_locate_session() -> None:
"""Stop the active locate session.""" """Stop the active locate session."""
global _session global _session
# Release the lock before stop() so concurrent status/SSE requests
# aren't blocked for up to 3 s while the poll thread is joined.
session_to_stop = None
with _session_lock: with _session_lock:
if _session: session_to_stop = _session
_session.stop() _session = None
_session = None
if session_to_stop:
session_to_stop.stop()
def get_locate_session() -> LocateSession | None: def get_locate_session() -> LocateSession | None: