Compare commits

...

11 Commits

Author SHA1 Message Date
Smittix 99d52eafe7 chore: Bump version to v2.18.0
Bluetooth enhancements (service data inspector, appearance codes, MAC
cluster tracking, behavioral flags, IRK badges, distance estimation),
ACARS SoapySDR multi-backend support, dump1090 stale process cleanup,
GPS error state, and proximity radar/signal card UI improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:12:10 +00:00
Smittix 2a73318457 chore: Bump version to v2.17.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:00:32 +00:00
Smittix d8d08a8b1e 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>
2026-02-15 21:59:45 +00:00
Smittix c60769f795 Revise README for title and license updates
Updated project title, license, and acknowledgments in README.
2026-02-15 17:39:53 +00:00
Smittix 01f8324292 chore: Change license from MIT to Apache 2.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:38:27 +00:00
Smittix c66988cc1c fix: Add progress indicator for SatDump compilation in setup.sh
SatDump is a large C++ project that can take 10-30 minutes to compile.
Previously all build output was sent to /dev/null, making it appear
hung. Now shows a progress message every 30 seconds, sets time
expectations upfront, and displays the build log on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:54:47 +00:00
Smittix fac3d4359b fix: Patch acarsdec source for macOS Apple Silicon builds (fixes #136)
The upstream acarsdec uses pthread_tryjoin_np (a Linux-only GNU
extension) and has broken libacars linking on macOS. The setup script
now patches both issues at build time, along with the existing compiler
flag fix for ARM64.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:44:39 +00:00
Smittix d6f10d29ca fix: Correct DSC decoder phasing sequence handling, MMSI and position decoding
Strip ITU-R M.493 phasing symbols (120-126) after dot pattern sync before
decoding message content. Fix MMSI BCD digit trimming direction and correct
test symbol encodings for position and MMSI edge cases.
2026-02-15 09:58:05 +00:00
Smittix 332735cecf fix: Persist tracked satellites to database (fixes #135)
Satellites added via CelesTrak import or TLE paste are now stored in
SQLite and survive page reloads and app restarts. Adds CRUD API
endpoints and wires frontend sidebar + dashboard to use them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:15:21 +00:00
Smittix b04e335f49 docs: Remove DMR references while feature is temporarily disabled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:37:42 +00:00
Smittix 75e50a1cd4 docs: Add Sub-GHz, APRS, DMR, weather sat, and other missing features to docs
Update README, FEATURES.md, USAGE.md, and GitHub Pages index.html with
all current modes including Sub-GHz analyzer, APRS, utility meters,
DMR digital voice, listening post, weather satellites, WebSDR, HF SSTV,
and AIS vessel tracking. Update mode count from 15+ to 20+.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:35:56 +00:00
50 changed files with 6534 additions and 747 deletions
+1
View File
@@ -41,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
bluez \ bluez \
bluetooth \ bluetooth \
# GPS support # GPS support
gpsd \
gpsd-clients \ gpsd-clients \
# Utilities # Utilities
# APRS # APRS
+196 -17
View File
@@ -1,21 +1,200 @@
MIT License
Copyright (c) 2025 smittix Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all 1. Definitions.
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR "License" shall mean the terms and conditions for use, reproduction,
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, and distribution as defined by Sections 1 through 9 of this document.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER "Licensor" shall mean the copyright owner or entity authorized by
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, the copyright owner that is granting the License.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an OpenPGP
key and encrypt outgoing communications.
Copyright 2025 smittix
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+14 -7
View File
@@ -28,19 +28,23 @@ Support the developer of this open-source project
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng - **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433 - **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar - **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring - **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec - **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer - **Listening Post** - Wideband frequency scanner with real-time audio monitoring
- **Listening Post** - Frequency scanner with audio monitoring - **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump - **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station - **ISS SSTV** - Slow-scan TV image reception from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies - **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
- **Satellite Tracking** - Pass prediction using TLE data - **APRS** - Amateur packet radio position reports and telemetry via direwolf
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
- **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
@@ -224,7 +228,7 @@ This project was developed using AI as a coding partner, combining human directi
## License ## License
MIT License - see [LICENSE](LICENSE) Apache 2.0 License - see [LICENSE](LICENSE)
## Author ## Author
@@ -238,6 +242,8 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[dump1090](https://github.com/flightaware/dump1090) | [dump1090](https://github.com/flightaware/dump1090) |
[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) |
[rtl_amr](https://github.com/bemasher/rtlamr) |
[aircrack-ng](https://www.aircrack-ng.org/) | [aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) | [Leaflet.js](https://leafletjs.com/) |
[SatDump](https://github.com/SatDump/SatDump) | [SatDump](https://github.com/SatDump/SatDump) |
@@ -252,3 +258,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
+2 -1
View File
@@ -29,7 +29,7 @@ from flask import Flask, render_template, jsonify, send_file, Response, request,
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
from utils.sdr import SDRFactory from utils.sdr import SDRFactory
from utils.cleanup import DataStore, cleanup_manager from utils.cleanup import DataStore, cleanup_manager
from utils.constants import ( from utils.constants import (
@@ -877,6 +877,7 @@ def main() -> None:
# Clean up any stale processes from previous runs # Clean up any stale processes from previous runs
cleanup_stale_processes() cleanup_stale_processes()
cleanup_stale_dump1090()
# Initialize database for settings storage # Initialize database for settings storage
from utils.database import init_db from utils.database import init_db
+23 -2
View File
@@ -7,10 +7,32 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.16.0" VERSION = "2.18.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.18.0",
"date": "February 2026",
"highlights": [
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
"ADS-B: stale dump1090 process cleanup via PID file tracking",
"GPS: error state indicator and UI refinements",
"Proximity radar and signal card UI improvements",
]
},
{
"version": "2.17.0",
"date": "February 2026",
"highlights": [
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
]
},
{ {
"version": "2.16.0", "version": "2.16.0",
"date": "February 2026", "date": "February 2026",
@@ -18,7 +40,6 @@ CHANGELOG = [
"Sub-GHz analyzer with real-time RF capture and protocol decoding", "Sub-GHz analyzer with real-time RF capture and protocol decoding",
"Weather satellite auto-scheduler with polar plot and ground track map", "Weather satellite auto-scheduler with polar plot and ground track map",
"SatDump support for local (non-Docker) installs via setup.sh", "SatDump support for local (non-Docker) installs via setup.sh",
"DMR audio output, frequency persistence, and bookmarks",
"Shared waterfall UI across SDR modes", "Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes", "Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)", "Multi-arch Docker build support (amd64 + arm64)",
+103
View File
@@ -16,6 +16,14 @@ Complete feature list for all modules.
- **Doorbells, remotes, and IoT devices** - **Doorbells, remotes, and IoT devices**
- **Smart meters** and utility monitors - **Smart meters** and utility monitors
## Sub-GHz Analyzer
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
- **Protocol decoding** - identify and decode common Sub-GHz protocols
- **Signal replay/transmit** capabilities for authorized testing
- **Wideband spectrum analysis** with real-time visualization
- **I/Q capture** - record raw samples for offline analysis
## AIS Vessel Tracking ## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz - **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
@@ -84,6 +92,55 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **SDR conflict detection** - Prevents device collisions with AIS tracking - **SDR conflict detection** - Prevents device collisions with AIS tracking
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency - **Alert summary** - Dashboard counts for unacknowledged distress/urgency
## ACARS Messaging
- **Real-time ACARS decoding** via acarsdec
- **Aircraft datalink messages** - operational, weather, and position reports
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **Message filtering** - filter by message type, flight, or registration
## Listening Post
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
- **Real-time audio monitoring** with FM and SSB demodulation
- **Cross-module frequency routing** from scanner to decoders
- **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
## Weather Satellites
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
- **Auto-scheduler** with pass prediction and automatic capture
- **Polar plot** - real-time satellite position on azimuth/elevation display
- **Ground track map** - orbit path with past/future trajectory
- **Image gallery** with timestamped decoded imagery
## WebSDR
- **KiwiSDR network integration** for remote HF/shortwave listening
- **WebSocket audio streaming** from remote receivers
- **Receiver discovery** with automatic caching
- **Frequency tuning** with band presets
## HF SSTV
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
- **Predefined frequency lookup** for active SSTV calling frequencies
- **Image gallery** with decoded transmissions
## APRS
- **Amateur packet radio** position reports and telemetry via direwolf
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
- **Real-time position tracking** on interactive map
- **Message and telemetry display** from APRS network
## Utility Meter Reading
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
- **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration
## Satellite Tracking ## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track - **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -131,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.
+126
View File
@@ -57,6 +57,32 @@ INTERCEPT automatically detects known trackers:
- Samsung SmartTag - Samsung SmartTag
- Chipolo - Chipolo
## Sub-GHz Analyzer
1. **Connect HackRF** - Plug in your HackRF One device
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
3. **Start Capture** - Click "Start Capture" to begin signal analysis
4. **View Spectrum** - Real-time spectrum visualization of the selected band
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
### Supported Protocols
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
## Listening Post
1. **Select Hardware** - Choose your SDR type
2. **Set Frequency Range** - Define start and end frequencies for scanning
3. **Start Scanning** - Click "Start Scan" for wideband sweep
4. **View Signals** - Discovered signals are listed with frequency and SNR
5. **Tune In** - Click a signal to tune the audio demodulator
6. **Listen** - Real-time audio plays in your browser
### Demodulation Modes
- **FM** - Narrowband and wideband FM
- **SSB** - Upper and lower sideband for amateur radio and shortwave
## Aircraft Mode (ADS-B) ## Aircraft Mode (ADS-B)
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb) 1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
@@ -165,6 +191,106 @@ If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HIST
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.) 3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
4. Select satellites to add 4. Select satellites to add
## Weather Satellites
1. **Set Location** - Enter observer coordinates or use GPS
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
3. **View Passes** - Upcoming passes shown with polar plot and ground track
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
5. **View Images** - Decoded imagery appears in the gallery
### Auto-Scheduler
Enable the auto-scheduler to automatically capture passes:
- Calculates upcoming NOAA and Meteor passes for your location
- Starts SatDump at the correct time and frequency
- Decoded images are saved with timestamps
## AIS Vessel Tracking
1. **Select Hardware** - Choose your SDR type
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
3. **View Map** - Vessels appear on the interactive maritime map
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
### VHF DSC Channel 70
Digital Selective Calling monitoring runs alongside AIS:
- Distress, Urgency, Safety, and Routine messages
- Distress positions plotted with pulsing alert markers
- Audio alerts for critical messages
## APRS
1. **Select Hardware** - Choose your SDR type
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
4. **View Map** - Station positions appear on the interactive map
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
## Utility Meters
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
## BT Locate (SAR Device Location)
1. **Set Target** - Enter one or more target identifiers:
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
2. **Choose Environment** - Select the RF environment preset:
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
- **Indoor** (n=3.0) - For buildings with walls and obstacles
3. **Start Locate** - Click "Start Locate" to begin tracking
4. **Monitor HUD** - The proximity display shows:
- Proximity band (IMMEDIATE / NEAR / FAR)
- Estimated distance in meters
- Raw RSSI and smoothed RSSI average
- Detection count and GPS-tagged points
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
7. **Review Trail** - Check the map for GPS-tagged detection trail
### Hand-off from Bluetooth Mode
1. Open Bluetooth scanning mode and find the target device
2. Click the "Locate" button on the device card
3. BT Locate opens with the device pre-filled
4. Click "Start Locate" to begin tracking
### Tips
- For devices with address randomization (iPhones, modern Android), use the IRK method
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
- Clear the trail when starting a new search area
## GPS Mode
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
2. **View Map** - Your position appears on the interactive map with a track trail
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
4. **Record Track** - Enable track recording to save your path
### Tips
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
- GPS fix may take 30-60 seconds after cold start
- Accuracy improves with more satellites in view
## Meshtastic
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
2. **Start** - Click "Start" to connect to the mesh network
3. **View Messages** - Real-time message stream from the mesh
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
5. **Send Messages** - Type messages to broadcast on the mesh
## Remote Agents (Distributed SIGINT) ## Remote Agents (Distributed SIGINT)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller. Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+59 -1
View File
@@ -35,7 +35,7 @@
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
<div class="stat"> <div class="stat">
<span class="stat-value">15+</span> <span class="stat-value">20+</span>
<span class="stat-label">Modes</span> <span class="stat-label">Modes</span>
</div> </div>
<div class="stat"> <div class="stat">
@@ -77,6 +77,12 @@
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p> <p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div> </div>
<div class="feature-card">
<div class="feature-icon">📻</div>
<h3>Sub-GHz Analyzer</h3>
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
</div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">📻</div> <div class="feature-icon">📻</div>
<h3>Listening Post</h3> <h3>Listening Post</h3>
@@ -101,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>
@@ -143,11 +161,47 @@
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p> <p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div> </div>
<div class="feature-card">
<div class="feature-icon">🌧️</div>
<h3>Weather Satellites</h3>
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
</div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon">🖼️</div> <div class="feature-icon">🖼️</div>
<h3>ISS SSTV</h3> <h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p> <p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</div> </div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div>
<div class="feature-card">
<div class="feature-icon">✈️</div>
<h3>ACARS</h3>
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📍</div>
<h3>APRS</h3>
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<h3>WebSDR</h3>
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Utility Meters</h3>
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -194,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>
+1 -1
View File
@@ -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):
+3 -3
View File
@@ -1,10 +1,10 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.16.0" version = "2.18.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
license = {text = "MIT"} license = {text = "Apache-2.0"}
authors = [ authors = [
{name = "Intercept Contributors"} {name = "Intercept Contributors"}
] ]
@@ -14,7 +14,7 @@ classifiers = [
"Environment :: Web Environment", "Environment :: Web Environment",
"Framework :: Flask", "Framework :: Flask",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Operating System :: MacOS", "Operating System :: MacOS",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
+3
View File
@@ -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
+2
View File
@@ -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
+28 -5
View File
@@ -20,6 +20,7 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
@@ -250,12 +251,22 @@ def start_acars() -> Response:
acars_message_count = 0 acars_message_count = 0
acars_last_message_time = None acars_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
# Build acarsdec command # Build acarsdec command
# Different forks have different syntax: # Different forks have different syntax:
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ... # - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ... # - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ... # - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
# Note: gain/ppm must come BEFORE -r # SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
# Note: gain/ppm must come BEFORE -r/-d
json_flag = get_acarsdec_json_flag(acarsdec_path) json_flag = get_acarsdec_json_flag(acarsdec_path)
cmd = [acarsdec_path] cmd = [acarsdec_path]
if json_flag == '--output': if json_flag == '--output':
@@ -266,21 +277,33 @@ def start_acars() -> Response:
else: else:
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x) cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
# Add gain if not auto (must be before -r) # Add gain if not auto (must be before -r/-d)
if gain and str(gain) != '0': if gain and str(gain) != '0':
cmd.extend(['-g', str(gain)]) cmd.extend(['-g', str(gain)])
# Add PPM correction if specified (must be before -r) # Add PPM correction if specified (must be before -r/-d)
if ppm and str(ppm) != '0': if ppm and str(ppm) != '0':
cmd.extend(['-p', str(ppm)]) cmd.extend(['-p', str(ppm)])
# Add device and frequencies # Add device and frequencies
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device> if is_soapy:
if json_flag == '--output': # SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
builder = SDRFactory.get_builder(sdr_type)
device_str = builder._build_device_string(sdr_device)
if json_flag == '--output':
cmd.extend(['-m', '256'])
cmd.extend(['--soapysdr', device_str])
else:
cmd.extend(['-d', device_str])
elif json_flag == '--output':
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span) # Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
cmd.extend(['-m', '256']) cmd.extend(['-m', '256'])
cmd.extend(['--rtlsdr', str(device)]) cmd.extend(['--rtlsdr', str(device)])
else: else:
# TLeconte fork RTL-SDR: -r <device>
cmd.extend(['-r', str(device)]) cmd.extend(['-r', str(device)])
cmd.extend(frequencies) cmd.extend(frequencies)
+7
View File
@@ -38,6 +38,7 @@ from config import (
SHARED_OBSERVER_LOCATION_ENABLED, SHARED_OBSERVER_LOCATION_ENABLED,
) )
from utils.logging import adsb_logger as logger from utils.logging import adsb_logger as logger
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
from utils.validation import ( from utils.validation import (
validate_device_index, validate_gain, validate_device_index, validate_gain,
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
@@ -633,6 +634,9 @@ def start_adsb():
'session': session 'session': session
}) })
# Kill any stale app-spawned dump1090 from a previous run before checking the port
cleanup_stale_dump1090()
# Check if dump1090 is already running externally (e.g., user started it manually) # Check if dump1090 is already running externally (e.g., user started it manually)
existing_service = check_dump1090_service() existing_service = check_dump1090_service()
if existing_service: if existing_service:
@@ -685,6 +689,7 @@ def start_adsb():
except (ProcessLookupError, OSError): except (ProcessLookupError, OSError):
pass pass
app_module.adsb_process = None app_module.adsb_process = None
clear_dump1090_pid()
logger.info("Killed stale ADS-B process") logger.info("Killed stale ADS-B process")
# Check if device is available before starting local dump1090 # Check if device is available before starting local dump1090
@@ -721,6 +726,7 @@ def start_adsb():
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True # Create new process group for clean shutdown start_new_session=True # Create new process group for clean shutdown
) )
write_dump1090_pid(app_module.adsb_process.pid)
time.sleep(DUMP1090_START_WAIT) time.sleep(DUMP1090_START_WAIT)
@@ -819,6 +825,7 @@ def stop_adsb():
except (ProcessLookupError, OSError): except (ProcessLookupError, OSError):
pass pass
app_module.adsb_process = None app_module.adsb_process = None
clear_dump1090_pid()
logger.info("ADS-B process stopped") logger.info("ADS-B process stopped")
# Release device from registry # Release device from registry
+3 -3
View File
@@ -229,7 +229,7 @@ def start_scan():
rssi_threshold = data.get('rssi_threshold', -100) rssi_threshold = data.get('rssi_threshold', -100)
# Validate mode # Validate mode
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl') valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
if mode not in valid_modes: if mode not in valid_modes:
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400 return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
@@ -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
View 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'})
+99 -32
View File
@@ -4,19 +4,24 @@ 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.gps import (
GPSPosition,
GPSSkyData,
detect_gps_devices,
get_current_position,
get_gps_reader,
is_gpsd_running,
start_gpsd,
start_gpsd_daemon,
stop_gps,
stop_gpsd_daemon,
)
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import format_sse
from utils.gps import (
get_gps_reader,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition,
)
logger = get_logger('intercept.gps') logger = get_logger('intercept.gps')
@@ -29,12 +34,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
@@ -45,36 +62,44 @@ def auto_connect_gps():
Automatically connect to gpsd if available. Automatically connect to gpsd if available.
Called on page load to seamlessly enable GPS if gpsd is running. Called on page load to seamlessly enable GPS if gpsd is running.
If gpsd is not running, attempts to detect GPS devices and start gpsd.
Returns current status if already connected. Returns current status if already connected.
""" """
import socket
# Check if already running # Check if already running
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
host = 'localhost' host = 'localhost'
port = 2947 port = 2947
# First check if gpsd is reachable # If gpsd isn't running, try to detect a device and start it
try: if not is_gpsd_running(host, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) devices = detect_gps_devices()
sock.settimeout(1.0) if not devices:
sock.connect((host, port)) return jsonify({
sock.close() 'status': 'unavailable',
except Exception: 'message': 'No GPS device detected'
return jsonify({ })
'status': 'unavailable',
'message': 'gpsd not running' # Try to start gpsd with the first detected device
}) device_path = devices[0]['path']
success, msg = start_gpsd_daemon(device_path, host, port)
if not success:
return jsonify({
'status': 'unavailable',
'message': msg,
'devices': devices,
})
logger.info(f"Auto-started gpsd on {device_path}")
# Clear the queue # Clear the queue
while not _gps_queue.empty(): while not _gps_queue.empty():
@@ -84,14 +109,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({
@@ -100,14 +128,26 @@ def auto_connect_gps():
}) })
@gps_bp.route('/devices')
def list_gps_devices():
"""List detected GPS serial devices."""
devices = detect_gps_devices()
return jsonify({
'devices': devices,
'gpsd_running': is_gpsd_running(),
})
@gps_bp.route('/stop', methods=['POST']) @gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader(): def stop_gps_reader():
"""Stop GPS client.""" """Stop GPS client and gpsd daemon if we started it."""
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()
stop_gpsd_daemon()
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -122,15 +162,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 +204,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:
+99
View File
@@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
from utils.database import (
get_tracked_satellites,
add_tracked_satellite,
bulk_add_tracked_satellites,
update_tracked_satellite,
remove_tracked_satellite,
)
from utils.logging import satellite_logger as logger from utils.logging import satellite_logger as logger
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
@@ -31,12 +38,32 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES) _tle_cache = dict(TLE_SATELLITES)
def _load_db_satellites_into_cache():
"""Load user-tracked satellites from DB into the TLE cache."""
global _tle_cache
try:
db_sats = get_tracked_satellites()
loaded = 0
for sat in db_sats:
if sat['tle_line1'] and sat['tle_line2']:
# Use a cache key derived from name (sanitised)
cache_key = sat['name'].replace(' ', '-').upper()
if cache_key not in _tle_cache:
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
loaded += 1
if loaded:
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
except Exception as e:
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
def init_tle_auto_refresh(): def init_tle_auto_refresh():
"""Initialize TLE auto-refresh. Called by app.py after initialization.""" """Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading import threading
def _auto_refresh_tle(): def _auto_refresh_tle():
try: try:
_load_db_satellites_into_cache()
updated = refresh_tle_data() updated = refresh_tle_data()
if updated: if updated:
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}") logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
@@ -543,3 +570,75 @@ def fetch_celestrak(category):
except Exception as e: except Exception as e:
logger.error(f"Error fetching CelesTrak data: {e}") logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'}) return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
# =============================================================================
# Tracked Satellites CRUD
# =============================================================================
@satellite_bp.route('/tracked', methods=['GET'])
def list_tracked_satellites():
"""Return all tracked satellites from the database."""
enabled_only = request.args.get('enabled', '').lower() == 'true'
sats = get_tracked_satellites(enabled_only=enabled_only)
return jsonify({'status': 'success', 'satellites': sats})
@satellite_bp.route('/tracked', methods=['POST'])
def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites."""
global _tle_cache
data = request.json
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data]
added = 0
for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '')
if not norad_id or not name:
continue
tle1 = sat.get('tle_line1', sat.get('tle1'))
tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True)
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
added += 1
# Also inject into TLE cache if we have TLE data
if tle1 and tle2:
cache_key = name.replace(' ', '-').upper()
_tle_cache[cache_key] = (name, tle1, tle2)
return jsonify({
'status': 'success',
'added': added,
'satellites': get_tracked_satellites(),
})
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
def update_tracked_satellite_endpoint(norad_id):
"""Update the enabled state of a tracked satellite."""
data = request.json or {}
enabled = data.get('enabled')
if enabled is None:
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok:
return jsonify({'status': 'success'})
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
def delete_tracked_satellite_endpoint(norad_id):
"""Remove a tracked satellite by NORAD ID."""
ok, msg = remove_tracked_satellite(str(norad_id))
if ok:
return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404
return jsonify({'status': 'error', 'message': msg}), status_code
+7 -1
View File
@@ -199,10 +199,16 @@ def start_sensor() -> Response:
thread.start() thread.start()
# Monitor stderr # Monitor stderr
# Filter noisy rtl_433 diagnostics that aren't useful to display
_stderr_noise = (
'bitbuffer_add_bit',
'row count limit',
)
def monitor_stderr(): def monitor_stderr():
for line in app_module.sensor_process.stderr: for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip() err = line.decode('utf-8', errors='replace').strip()
if err: if err and not any(noise in err for noise in _stderr_noise):
logger.debug(f"[rtl_433] {err}") logger.debug(f"[rtl_433] {err}")
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
+109 -15
View File
@@ -137,6 +137,14 @@ need_sudo() {
fi fi
} }
# Refresh sudo credential cache so long-running builds don't trigger
# mid-compilation password prompts (which can fail due to TTY issues
# inside subshells). Safe to call multiple times.
refresh_sudo() {
[[ -z "${SUDO:-}" ]] && return 0
sudo -v 2>/dev/null || true
}
detect_os() { detect_os() {
if [[ "${OSTYPE:-}" == "darwin"* ]]; then if [[ "${OSTYPE:-}" == "darwin"* ]]; then
OS="macos" OS="macos"
@@ -388,7 +396,7 @@ install_rtlamr_from_source() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
else else
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr $SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
fi fi
else else
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr $SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
@@ -430,7 +438,8 @@ install_multimon_ng_from_source_macos() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 multimon-ng /usr/local/bin/multimon-ng install -m 0755 multimon-ng /usr/local/bin/multimon-ng
else else
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng refresh_sudo
$SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng
fi fi
ok "multimon-ng installed successfully from source" ok "multimon-ng installed successfully from source"
) )
@@ -471,7 +480,8 @@ install_dsd_from_source() {
if [[ -w /usr/local/lib ]]; then if [[ -w /usr/local/lib ]]; then
make install >/dev/null 2>&1 make install >/dev/null 2>&1
else else
sudo make install >/dev/null 2>&1 refresh_sudo
$SUDO make install >/dev/null 2>&1
fi fi
else else
$SUDO make install >/dev/null 2>&1 $SUDO make install >/dev/null 2>&1
@@ -507,7 +517,8 @@ install_dsd_from_source() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
else else
sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true refresh_sudo
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
fi fi
else else
$SUDO make install >/dev/null 2>&1 \ $SUDO make install >/dev/null 2>&1 \
@@ -545,7 +556,8 @@ install_dump1090_from_source_macos() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 dump1090 /usr/local/bin/dump1090 install -m 0755 dump1090 /usr/local/bin/dump1090
else else
sudo install -m 0755 dump1090 /usr/local/bin/dump1090 refresh_sudo
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
fi fi
ok "dump1090 installed successfully from source" ok "dump1090 installed successfully from source"
else else
@@ -571,18 +583,54 @@ install_acarsdec_from_source_macos() {
|| { warn "Failed to clone acarsdec"; exit 1; } || { warn "Failed to clone acarsdec"; exit 1; }
cd "$tmp_dir/acarsdec" cd "$tmp_dir/acarsdec"
# Fix compiler flags for macOS Apple Silicon (ARM64)
# -march=native can fail with Apple Clang on M-series chips
# -Ofast is deprecated in modern Clang
if [[ "$(uname -m)" == "arm64" ]]; then
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
info "Patched compiler flags for Apple Silicon (arm64)"
fi
# Fix pthread_tryjoin_np (Linux-only GNU extension) for macOS
# Replace with pthread_join which provides equivalent behavior
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
sed -i '' 's/pthread_tryjoin_np(\([^,]*\), NULL)/pthread_join(\1, NULL)/g' rtl.c
info "Patched pthread_tryjoin_np for macOS compatibility"
fi
# Fix libacars linking on macOS (upstream issue #112)
# Use LIBACARS_LINK_LIBRARIES (full path) instead of LIBACARS_LIBRARIES (name only)
if grep -q 'LIBACARS_LIBRARIES' CMakeLists.txt 2>/dev/null; then
sed -i '' 's/${LIBACARS_LIBRARIES}/${LIBACARS_LINK_LIBRARIES}/g' CMakeLists.txt
info "Patched libacars linking for macOS"
fi
mkdir -p build && cd build mkdir -p build && cd build
# Set Homebrew paths for Apple Silicon (/opt/homebrew) or Intel (/usr/local)
HOMEBREW_PREFIX="$(brew --prefix)"
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
info "Compiling acarsdec..." info "Compiling acarsdec..."
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then build_log="$tmp_dir/acarsdec-build.log"
if cmake .. -Drtl=ON \
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
>"$build_log" 2>&1 \
&& make >>"$build_log" 2>&1; then
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 acarsdec /usr/local/bin/acarsdec install -m 0755 acarsdec /usr/local/bin/acarsdec
else else
sudo install -m 0755 acarsdec /usr/local/bin/acarsdec refresh_sudo
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
fi fi
ok "acarsdec installed successfully from source" ok "acarsdec installed successfully from source"
else else
warn "Failed to build acarsdec. ACARS decoding will not be available." warn "Failed to build acarsdec. ACARS decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi fi
) )
} }
@@ -611,7 +659,8 @@ install_aiscatcher_from_source_macos() {
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
else else
sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher refresh_sudo
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
fi fi
ok "AIS-catcher installed successfully from source" ok "AIS-catcher installed successfully from source"
else else
@@ -640,9 +689,24 @@ install_satdump_from_source_debian() {
cd "$tmp_dir/SatDump" cd "$tmp_dir/SatDump"
mkdir -p build && cd build mkdir -p build && cd build
info "Compiling SatDump (this may take a while)..." info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >/dev/null 2>&1 \ build_log="$tmp_dir/satdump-build.log"
&& make -j "$(nproc)" >/dev/null 2>&1; then
# Show periodic progress while building so the user knows it's not hung
(
while true; do
sleep 30
if [ -f "$build_log" ]; then
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
fi
done
) &
progress_pid=$!
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >"$build_log" 2>&1 \
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
$SUDO make install >/dev/null 2>&1 $SUDO make install >/dev/null 2>&1
$SUDO ldconfig $SUDO ldconfig
@@ -659,7 +723,10 @@ install_satdump_from_source_debian() {
ok "SatDump installed successfully." ok "SatDump installed successfully."
else else
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
warn "Failed to build SatDump from source. Weather satellite decoding will not be available." warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi fi
) )
} }
@@ -690,22 +757,49 @@ install_satdump_from_source_macos() {
cd "$tmp_dir/SatDump" cd "$tmp_dir/SatDump"
mkdir -p build && cd build mkdir -p build && cd build
info "Compiling SatDump (this may take a while)..." info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >/dev/null 2>&1 \ build_log="$tmp_dir/satdump-build.log"
&& make -j "$(sysctl -n hw.ncpu)" >/dev/null 2>&1; then
# Show periodic progress while building so the user knows it's not hung
(
while true; do
sleep 30
if [ -f "$build_log" ]; then
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
fi
done
) &
progress_pid=$!
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >"$build_log" 2>&1 \
&& make -j "$(sysctl -n hw.ncpu)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
if [[ -w /usr/local/bin ]]; then if [[ -w /usr/local/bin ]]; then
make install >/dev/null 2>&1 make install >/dev/null 2>&1
else else
sudo make install >/dev/null 2>&1 refresh_sudo
$SUDO make install >/dev/null 2>&1
fi fi
ok "SatDump installed successfully." ok "SatDump installed successfully."
else else
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
warn "Failed to build SatDump from source. Weather satellite decoding will not be available." warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi fi
) )
} }
install_macos_packages() { install_macos_packages() {
need_sudo
# Prime sudo credentials upfront so builds don't prompt mid-compilation
if [[ -n "${SUDO:-}" ]]; then
info "Some tools require sudo to install. You may be prompted for your password."
sudo -v || { fail "sudo authentication failed"; exit 1; }
fi
TOTAL_STEPS=19 TOTAL_STEPS=19
CURRENT_STEP=0 CURRENT_STEP=0
+133 -8
View File
@@ -4119,7 +4119,7 @@ header h1 .tagline {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
flex-shrink: 0; flex-shrink: 0;
height: 140px; max-height: 340px;
overflow: hidden; overflow: hidden;
} }
@@ -4140,7 +4140,9 @@ header h1 .tagline {
.bt-detail-body { .bt-detail-body {
padding: 8px 10px; padding: 8px 10px;
height: calc(100% - 30px); height: auto;
max-height: calc(100% - 30px);
overflow-y: auto;
} }
.bt-detail-placeholder { .bt-detail-placeholder {
@@ -4319,6 +4321,110 @@ header h1 .tagline {
color: #9fffd1; color: #9fffd1;
} }
/* Service Data Inspector */
.bt-detail-service-inspector {
margin-bottom: 6px;
}
.bt-inspector-toggle {
font-size: 10px;
color: var(--accent-cyan);
cursor: pointer;
padding: 3px 0;
user-select: none;
}
.bt-inspector-toggle:hover {
color: #fff;
}
.bt-inspector-arrow {
display: inline-block;
transition: transform 0.2s;
font-size: 9px;
}
.bt-inspector-arrow.open {
transform: rotate(90deg);
}
.bt-inspector-content {
background: var(--bg-secondary);
border-radius: 3px;
padding: 6px 8px;
margin-top: 4px;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
max-height: 100px;
overflow-y: auto;
word-break: break-all;
}
.bt-inspector-row {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.bt-inspector-row:last-child {
border-bottom: none;
}
.bt-inspector-label {
color: var(--text-dim);
flex-shrink: 0;
}
.bt-inspector-value {
color: var(--text-primary);
text-align: right;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* MAC Cluster Badge */
.bt-mac-cluster-badge {
display: inline-block;
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
font-size: 8px;
font-weight: 600;
padding: 1px 4px;
border-radius: 3px;
margin-left: 6px;
vertical-align: middle;
}
/* Behavioral Flag Badges */
.bt-flag-badge {
display: inline-block;
font-size: 8px;
font-weight: 600;
padding: 1px 4px;
border-radius: 3px;
margin-left: 3px;
vertical-align: middle;
}
.bt-flag-badge.persistent {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.bt-flag-badge.beacon-like {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.bt-flag-badge.strong-stable {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
/* Selected device highlight */ /* Selected device highlight */
.bt-device-row.selected { .bt-device-row.selected {
background: rgba(0, 212, 255, 0.1); background: rgba(0, 212, 255, 0.1);
@@ -4469,14 +4575,15 @@ header h1 .tagline {
.bt-row-main { .bt-row-main {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
gap: 12px; gap: 12px;
} }
.bt-row-left { .bt-row-left {
display: flex; display: flex;
align-items: center; align-items: baseline;
gap: 8px; flex-wrap: wrap;
gap: 4px 8px;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
@@ -4521,13 +4628,25 @@ header h1 .tagline {
color: #22c55e; color: #22c55e;
} }
.bt-irk-badge {
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
border: 1px solid rgba(168, 85, 247, 0.3);
}
.bt-device-name { .bt-device-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap; overflow-wrap: break-word;
overflow: hidden; word-break: break-word;
text-overflow: ellipsis; min-width: 0;
} }
.bt-rssi-container { .bt-rssi-container {
@@ -4584,6 +4703,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
View 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;
}
}
+337
View File
@@ -0,0 +1,337 @@
/* 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-dot.error {
background: #ff4444;
box-shadow: 0 0 6px rgba(255, 68, 68, 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;
}
}
+144 -25
View File
@@ -36,6 +36,7 @@ const ProximityRadar = (function() {
let isHovered = false; let isHovered = false;
let renderPending = false; let renderPending = false;
let renderTimer = null; let renderTimer = null;
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
/** /**
* Initialize the radar component * Initialize the radar component
@@ -119,6 +120,36 @@ const ProximityRadar = (function() {
svg = container.querySelector('svg'); svg = container.querySelector('svg');
// Event delegation on the devices group (survives innerHTML rebuilds)
const devicesGroup = svg.querySelector('.radar-devices');
devicesGroup.addEventListener('click', (e) => {
const deviceEl = e.target.closest('.radar-device');
if (!deviceEl) return;
const deviceKey = deviceEl.getAttribute('data-device-key');
if (onDeviceClick && deviceKey) {
// Lock out re-renders briefly so the DOM stays stable after click
interactionLockUntil = Date.now() + 500;
onDeviceClick(deviceKey);
}
});
devicesGroup.addEventListener('mouseenter', (e) => {
if (e.target.closest('.radar-device')) {
isHovered = true;
}
}, true); // capture phase so we catch enter on child elements
devicesGroup.addEventListener('mouseleave', (e) => {
if (e.target.closest('.radar-device')) {
isHovered = false;
if (renderPending) {
renderPending = false;
renderDevices();
}
}
}, true);
// Add sweep animation // Add sweep animation
animateSweep(); animateSweep();
} }
@@ -165,8 +196,8 @@ const ProximityRadar = (function() {
devices.set(device.device_key, device); devices.set(device.device_key, device);
}); });
// Defer render while user is hovering to prevent DOM rebuild flicker // Defer render while user is hovering or interacting to prevent DOM rebuild flicker
if (isHovered) { if (isHovered || Date.now() < interactionLockUntil) {
renderPending = true; renderPending = true;
return; return;
} }
@@ -229,7 +260,7 @@ const ProximityRadar = (function() {
style="cursor: pointer;"> style="cursor: pointer;">
<!-- Invisible hit area to prevent hover flicker --> <!-- Invisible hit area to prevent hover flicker -->
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" /> <circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8"> ${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/> <animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/> <animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''} </circle>` : ''}
@@ -244,24 +275,6 @@ const ProximityRadar = (function() {
}).join(''); }).join('');
devicesGroup.innerHTML = dots; devicesGroup.innerHTML = dots;
// Attach event handlers
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
el.addEventListener('click', (e) => {
const deviceKey = el.getAttribute('data-device-key');
if (onDeviceClick && deviceKey) {
onDeviceClick(deviceKey);
}
});
el.addEventListener('mouseenter', () => { isHovered = true; });
el.addEventListener('mouseleave', () => {
isHovered = false;
if (renderPending) {
renderPending = false;
renderDevices();
}
});
});
} }
/** /**
@@ -345,19 +358,125 @@ const ProximityRadar = (function() {
} }
/** /**
* Highlight a specific device on the radar * Highlight a specific device on the radar (in-place update, no full re-render)
*/ */
function highlightDevice(deviceKey) { function highlightDevice(deviceKey) {
const prev = selectedDeviceKey;
selectedDeviceKey = deviceKey; selectedDeviceKey = deviceKey;
renderDevices();
if (!svg) { return; }
const devicesGroup = svg.querySelector('.radar-devices');
if (!devicesGroup) { return; }
// Remove highlight from previously selected node
if (prev && prev !== deviceKey) {
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
if (oldEl) {
oldEl.classList.remove('selected');
// Remove animated selection ring
const ring = oldEl.querySelector('.radar-select-ring');
if (ring) ring.remove();
// Restore dot opacity
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
const device = devices.get(prev);
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
dot.setAttribute('stroke', dot.getAttribute('fill'));
dot.setAttribute('stroke-width', '1');
}
}
}
// Add highlight to newly selected node
if (deviceKey) {
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
if (newEl) {
applySelectionToElement(newEl, deviceKey);
} else {
// Node not in DOM yet; full render needed on next cycle
renderDevices();
}
}
} }
/** /**
* Clear device highlighting * Apply selection styling to a radar device element in-place
*/
function applySelectionToElement(el, deviceKey) {
el.classList.add('selected');
const device = devices.get(deviceKey);
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
// Update dot styling
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
dot.setAttribute('fill-opacity', '1');
dot.setAttribute('stroke', '#00d4ff');
dot.setAttribute('stroke-width', '2');
}
// Add animated selection ring if not already present
if (!el.querySelector('.radar-select-ring')) {
const ns = 'http://www.w3.org/2000/svg';
const ring = document.createElementNS(ns, 'circle');
ring.classList.add('radar-select-ring');
ring.setAttribute('r', dotSize + 8);
ring.setAttribute('fill', 'none');
ring.setAttribute('stroke', '#00d4ff');
ring.setAttribute('stroke-width', '2');
ring.setAttribute('stroke-opacity', '0.8');
const animR = document.createElementNS(ns, 'animate');
animR.setAttribute('attributeName', 'r');
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
animR.setAttribute('dur', '1.5s');
animR.setAttribute('repeatCount', 'indefinite');
ring.appendChild(animR);
const animO = document.createElementNS(ns, 'animate');
animO.setAttribute('attributeName', 'stroke-opacity');
animO.setAttribute('values', '0.8;0.4;0.8');
animO.setAttribute('dur', '1.5s');
animO.setAttribute('repeatCount', 'indefinite');
ring.appendChild(animO);
// Insert after the hit area
const hitArea = el.querySelector('.radar-device-hitarea');
if (hitArea && hitArea.nextSibling) {
el.insertBefore(ring, hitArea.nextSibling);
} else {
el.insertBefore(ring, el.firstChild);
}
}
}
/**
* Clear device highlighting (in-place update, no full re-render)
*/ */
function clearHighlight() { function clearHighlight() {
const prev = selectedDeviceKey;
selectedDeviceKey = null; selectedDeviceKey = null;
renderDevices();
if (!svg || !prev) { return; }
const devicesGroup = svg.querySelector('.radar-devices');
if (!devicesGroup) { return; }
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
if (oldEl) {
oldEl.classList.remove('selected');
const ring = oldEl.querySelector('.radar-select-ring');
if (ring) ring.remove();
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
const device = devices.get(prev);
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
dot.setAttribute('stroke', dot.getAttribute('fill'));
dot.setAttribute('stroke-width', '1');
}
}
} }
/** /**
+7 -1
View File
@@ -302,7 +302,13 @@ const SignalCards = (function() {
*/ */
function formatRelativeTime(timestamp) { function formatRelativeTime(timestamp) {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp); let date = new Date(timestamp);
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
const today = new Date();
date = new Date(today.toDateString() + ' ' + timestamp);
}
if (isNaN(date.getTime())) return timestamp;
const now = new Date(); const now = new Date();
const diff = Math.floor((now - date) / 1000); const diff = Math.floor((now - date) / 1000);
+215 -3
View File
@@ -356,7 +356,9 @@ const BluetoothMode = (function() {
// Update panel elements // Update panel elements
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address); document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
document.getElementById('btDetailAddress').textContent = device.address; document.getElementById('btDetailAddress').textContent = isUuidAddress(device)
? 'CB: ' + device.address
: device.address;
// RSSI // RSSI
const rssiEl = document.getElementById('btDetailRssi'); const rssiEl = document.getElementById('btDetailRssi');
@@ -458,8 +460,98 @@ const BluetoothMode = (function() {
? new Date(device.last_seen).toLocaleTimeString() ? new Date(device.last_seen).toLocaleTimeString()
: '--'; : '--';
// New stat cells
document.getElementById('btDetailTxPower').textContent = device.tx_power != null
? device.tx_power + ' dBm' : '--';
document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null
? device.seen_rate.toFixed(1) + '/min' : '--';
// Stability from variance
const stabilityEl = document.getElementById('btDetailStability');
if (device.rssi_variance != null) {
let stabLabel, stabColor;
if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; }
else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; }
else { stabLabel = 'Unstable'; stabColor = '#ef4444'; }
stabilityEl.textContent = stabLabel;
stabilityEl.style.color = stabColor;
} else {
stabilityEl.textContent = '--';
stabilityEl.style.color = '';
}
// Distance with confidence
const distEl = document.getElementById('btDetailDistance');
if (device.estimated_distance_m != null) {
const confPct = Math.round((device.distance_confidence || 0) * 100);
distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%';
} else {
distEl.textContent = '--';
}
// Appearance badge
if (device.appearance_name) {
badgesHtml += '<span class="bt-detail-badge flag">' + escapeHtml(device.appearance_name) + '</span>';
badgesEl.innerHTML = badgesHtml;
}
// MAC cluster indicator
const macClusterEl = document.getElementById('btDetailMacCluster');
if (macClusterEl) {
if (device.mac_cluster_count > 1) {
macClusterEl.textContent = device.mac_cluster_count + ' MACs';
macClusterEl.style.display = '';
} else {
macClusterEl.style.display = 'none';
}
}
// Service data inspector
const inspectorEl = document.getElementById('btDetailServiceInspector');
const inspectorContent = document.getElementById('btInspectorContent');
if (inspectorEl && inspectorContent) {
const hasData = device.manufacturer_bytes || device.appearance != null ||
(device.service_data && Object.keys(device.service_data).length > 0);
if (hasData) {
inspectorEl.style.display = '';
let inspHtml = '';
if (device.appearance != null) {
const name = device.appearance_name || '';
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Appearance</span><span class="bt-inspector-value">0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '</span></div>';
}
if (device.manufacturer_bytes) {
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Mfr Data</span><span class="bt-inspector-value">' + escapeHtml(device.manufacturer_bytes) + '</span></div>';
}
if (device.service_data) {
Object.entries(device.service_data).forEach(([uuid, hex]) => {
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">' + escapeHtml(uuid) + '</span><span class="bt-inspector-value">' + escapeHtml(hex) + '</span></div>';
});
}
inspectorContent.innerHTML = inspHtml;
} else {
inspectorEl.style.display = 'none';
}
}
updateWatchlistButton(device); updateWatchlistButton(device);
// IRK
const irkContainer = document.getElementById('btDetailIrk');
if (irkContainer) {
if (device.has_irk) {
irkContainer.style.display = 'block';
const irkVal = document.getElementById('btDetailIrkValue');
if (irkVal) {
const label = device.irk_source_name
? device.irk_source_name + ' — ' + device.irk_hex
: device.irk_hex;
irkVal.textContent = label;
}
} else {
irkContainer.style.display = 'none';
}
}
// Services // Services
const servicesContainer = document.getElementById('btDetailServices'); const servicesContainer = document.getElementById('btDetailServices');
const servicesList = document.getElementById('btDetailServicesList'); const servicesList = document.getElementById('btDetailServicesList');
@@ -600,9 +692,25 @@ const BluetoothMode = (function() {
if (parts.length === 6) { if (parts.length === 6) {
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5]; return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
} }
// CoreBluetooth UUID format (8-4-4-4-12)
if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) {
return address.substring(0, 8) + '...';
}
return address; return address;
} }
function isUuidAddress(device) {
return device.address_type === 'uuid';
}
function formatAddress(device) {
if (!device || !device.address) return '--';
if (isUuidAddress(device)) {
return device.address.substring(0, 8) + '-...' + device.address.slice(-4);
}
return device.address;
}
/** /**
* Check system capabilities * Check system capabilities
*/ */
@@ -660,6 +768,12 @@ const BluetoothMode = (function() {
hideCapabilityWarning(); hideCapabilityWarning();
} }
// Show/hide Ubertooth option based on capabilities
const ubertoothOption = document.getElementById('btScanModeUbertooth');
if (ubertoothOption) {
ubertoothOption.style.display = data.has_ubertooth ? '' : 'none';
}
if (scanModeSelect && data.preferred_backend) { if (scanModeSelect && data.preferred_backend) {
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`); const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
if (option) option.selected = true; if (option) option.selected = true;
@@ -1085,7 +1199,7 @@ const BluetoothMode = (function() {
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' + '<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' + '<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' + '<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
'</div>' + '</div>' +
evidenceHtml + evidenceHtml +
@@ -1142,7 +1256,7 @@ const BluetoothMode = (function() {
const displayName = device.name || formatDeviceId(device.address); const displayName = device.name || formatDeviceId(device.address);
const name = escapeHtml(displayName); const name = escapeHtml(displayName);
const addr = escapeHtml(device.address || 'Unknown'); const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
const seenCount = device.seen_count || 0; const seenCount = device.seen_count || 0;
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'"); const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
@@ -1167,6 +1281,12 @@ const BluetoothMode = (function() {
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>'; trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
} }
// IRK badge - show if paired IRK is available
let irkBadge = '';
if (device.has_irk) {
irkBadge = '<span class="bt-irk-badge">IRK</span>';
}
// Risk badge - show if risk score is significant // Risk badge - show if risk score is significant
let riskBadge = ''; let riskBadge = '';
if (riskScore >= 0.3) { if (riskScore >= 0.3) {
@@ -1184,9 +1304,36 @@ const BluetoothMode = (function() {
statusDot = '<span class="bt-status-dot known"></span>'; statusDot = '<span class="bt-status-dot known"></span>';
} }
// Distance display
const distM = device.estimated_distance_m;
let distStr = '';
if (distM != null) {
distStr = '~' + distM.toFixed(1) + 'm';
}
// Behavioral flag badges
const hFlags = device.heuristic_flags || [];
let flagBadges = '';
if (device.is_persistent || hFlags.includes('persistent')) {
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
}
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
}
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
}
// MAC cluster badge
let clusterBadge = '';
if (device.mac_cluster_count > 1) {
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
}
// Build secondary info line // Build secondary info line
let secondaryParts = [addr]; let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr); if (mfr) secondaryParts.push(mfr);
if (distStr) secondaryParts.push(distStr);
secondaryParts.push('Seen ' + seenCount + '×'); secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>'); if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local // Add agent name if not Local
@@ -1205,7 +1352,10 @@ const BluetoothMode = (function() {
protoBadge + protoBadge +
'<span class="bt-device-name">' + name + '</span>' + '<span class="bt-device-name">' + name + '</span>' +
trackerBadge + trackerBadge +
irkBadge +
riskBadge + riskBadge +
flagBadges +
clusterBadge +
'</div>' + '</div>' +
'<div class="bt-row-right">' + '<div class="bt-row-right">' +
'<div class="bt-rssi-container">' + '<div class="bt-rssi-container">' +
@@ -1216,6 +1366,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>';
} }
@@ -1295,6 +1450,18 @@ const BluetoothMode = (function() {
} }
} }
/**
* Toggle the service data inspector panel
*/
function toggleServiceInspector() {
const content = document.getElementById('btInspectorContent');
const arrow = document.getElementById('btInspectorArrow');
if (!content) return;
const open = content.style.display === 'none';
content.style.display = open ? '' : 'none';
if (arrow) arrow.classList.toggle('open', open);
}
// ========================================================================== // ==========================================================================
// Agent Handling // Agent Handling
// ========================================================================== // ==========================================================================
@@ -1391,6 +1558,48 @@ 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,
address_type: device.address_type || null,
irk_hex: device.irk_hex || null,
known_name: device.name || null,
known_manufacturer: device.manufacturer_name || null,
last_known_rssi: device.rssi_current,
tx_power: device.tx_power || null,
appearance_name: device.appearance_name || null,
fingerprint_id: device.fingerprint_id || null,
mac_cluster_count: device.mac_cluster_count || 0
});
}
}
// Public API // Public API
return { return {
init, init,
@@ -1404,6 +1613,9 @@ const BluetoothMode = (function() {
clearSelection, clearSelection,
copyAddress, copyAddress,
toggleWatchlist, toggleWatchlist,
locateDevice,
locateById,
toggleServiceInspector,
// Agent handling // Agent handling
handleAgentChange, handleAgentChange,
+749
View File
@@ -0,0 +1,749 @@
/**
* 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: '&copy; OSM &copy; 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 || '';
const addrDisplay = formatAddr(addr);
targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--';
}
// 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 isUuid(addr) {
return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr);
}
function formatAddr(addr) {
if (!addr) return '';
if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4);
return addr;
}
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 || formatAddr(deviceInfo.mac_address) || 'Unknown';
if (metaEl) {
const parts = [];
if (deviceInfo.mac_address) parts.push(formatAddr(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 ');
}
// Auto-fill IRK if available from scanner
if (deviceInfo.irk_hex) {
const irkInput = document.getElementById('btLocateIrk');
if (irkInput) irkInput.value = deviceInfo.irk_hex;
}
// 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,
};
})();
+406
View File
@@ -0,0 +1,406 @@
/**
* 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 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() {
updateConnectionUI(false, false, 'connecting');
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);
}
subscribeToStream();
// Ensure the global GPS stream is running
if (typeof startGpsStream === 'function' && !gpsEventSource) {
startGpsStream();
}
} else {
connected = false;
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
}
})
.catch(() => {
connected = false;
updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?');
});
}
function disconnect() {
unsubscribeFromStream();
fetch('/gps/stop', { method: 'POST' })
.then(() => {
connected = false;
updateConnectionUI(false);
});
}
function onGpsStreamData(data) {
if (!connected) return;
if (data.type === 'position') {
lastPosition = data;
updatePositionUI(data);
updateConnectionUI(true, true);
} else if (data.type === 'sky') {
lastSky = data;
updateSkyUI(data);
}
}
function subscribeToStream() {
// Subscribe to the global GPS stream instead of opening a separate SSE connection
if (typeof addGpsStreamSubscriber === 'function') {
addGpsStreamSubscriber(onGpsStreamData);
}
}
function unsubscribeFromStream() {
if (typeof removeGpsStreamSubscriber === 'function') {
removeGpsStreamSubscriber(onGpsStreamData);
}
}
// ========================
// UI Updates
// ========================
function updateConnectionUI(isConnected, hasFix, state, message) {
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 (state === 'connecting') dot.classList.add('waiting');
else if (state === 'error') dot.classList.add('error');
else if (isConnected && hasFix) dot.classList.add('connected');
else if (isConnected) dot.classList.add('waiting');
}
if (text) {
if (state === 'connecting') text.textContent = 'Connecting...';
else if (state === 'error') text.textContent = message || 'Connection failed';
else 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' : '';
connectBtn.disabled = state === 'connecting';
}
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() {
unsubscribeFromStream();
}
return {
init: init,
connect: connect,
disconnect: disconnect,
destroy: destroy,
};
})();
+6 -2
View File
@@ -3869,7 +3869,9 @@ sudo make install</code>
} }
function startAcars() { function startAcars() {
const device = document.getElementById('acarsDeviceSelect').value; const acarsSelect = document.getElementById('acarsDeviceSelect');
const device = acarsSelect.value;
const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
const frequencies = getAcarsRegionFreqs(); const frequencies = getAcarsRegionFreqs();
// Check if using agent mode // Check if using agent mode
@@ -3895,7 +3897,7 @@ sudo make install</code>
fetch(endpoint, { fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, frequencies, gain: '40' }) body: JSON.stringify({ device, frequencies, gain: '40', sdr_type })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -4101,6 +4103,7 @@ sudo make install</code>
devices.forEach((d, i) => { devices.forEach((d, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.index || i; opt.value = d.index || i;
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt); select.appendChild(opt);
}); });
@@ -4880,6 +4883,7 @@ sudo make install</code>
devices.forEach(device => { devices.forEach(device => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = device.index; opt.value = device.index;
opt.dataset.sdrType = device.sdr_type || 'rtlsdr';
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`; opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt); select.appendChild(opt);
}); });
+318 -54
View File
@@ -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') }}">
@@ -214,6 +216,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"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></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"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
<span class="mode-name">Bluetooth</span> <span class="mode-name">Bluetooth</span>
</button> </button>
<button class="mode-card mode-card-sm" onclick="selectMode('bt_locate')">
<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"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg></span>
<span class="mode-name">BT Locate</span>
</button>
</div> </div>
</div> </div>
@@ -248,6 +254,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>
@@ -433,6 +443,7 @@
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label> <label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()"> <select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
<option value="rtlsdr">RTL-SDR</option> <option value="rtlsdr">RTL-SDR</option>
<option value="sdrplay">SDRplay</option>
<option value="limesdr">LimeSDR</option> <option value="limesdr">LimeSDR</option>
<option value="hackrf">HackRF</option> <option value="hackrf">HackRF</option>
<option value="airspy">Airspy</option> <option value="airspy">Airspy</option>
@@ -538,6 +549,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 +567,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
@@ -780,7 +795,10 @@
<div class="bt-detail-top-row"> <div class="bt-detail-top-row">
<div class="bt-detail-identity"> <div class="bt-detail-identity">
<div class="bt-detail-name" id="btDetailName">Device Name</div> <div class="bt-detail-name" id="btDetailName">Device Name</div>
<div class="bt-detail-address" id="btDetailAddress">00:00:00:00:00:00</div> <div class="bt-detail-address">
<span id="btDetailAddress">00:00:00:00:00:00</span>
<span class="bt-mac-cluster-badge" id="btDetailMacCluster" style="display:none;"></span>
</div>
</div> </div>
<div class="bt-detail-rssi-display"> <div class="bt-detail-rssi-display">
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span> <span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
@@ -821,13 +839,42 @@
<span class="bt-detail-stat-label">Mfr ID</span> <span class="bt-detail-stat-label">Mfr ID</span>
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span> <span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
</div> </div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">TX Power</span>
<span class="bt-detail-stat-value" id="btDetailTxPower">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Seen Rate</span>
<span class="bt-detail-stat-value" id="btDetailSeenRate">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Stability</span>
<span class="bt-detail-stat-value" id="btDetailStability">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Distance</span>
<span class="bt-detail-stat-value" id="btDetailDistance">--</span>
</div>
</div>
<!-- Service Data Inspector (collapsible) -->
<div class="bt-detail-service-inspector" id="btDetailServiceInspector" style="display:none;">
<div class="bt-inspector-toggle" onclick="BluetoothMode.toggleServiceInspector()">
<span class="bt-inspector-arrow" id="btInspectorArrow">&#9656;</span> Raw Data
</div>
<div class="bt-inspector-content" id="btInspectorContent" style="display:none;">
</div>
</div> </div>
<div class="bt-detail-bottom-row"> <div class="bt-detail-bottom-row">
<div class="bt-detail-irk" id="btDetailIrk" style="display: none;">
<span class="bt-irk-badge">IRK</span>
<span class="bt-detail-irk-value" id="btDetailIrkValue" style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono);margin-left:6px;word-break:break-all;"></span>
</div>
<div class="bt-detail-services" id="btDetailServices" style="display: none;"> <div class="bt-detail-services" id="btDetailServices" style="display: none;">
<span class="bt-detail-services-list" id="btDetailServicesList"></span> <span class="bt-detail-services-list" id="btDetailServicesList"></span>
</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 +1090,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 +2172,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">&nbsp;</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">&nbsp;</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">&nbsp;</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">&middot;</span>
<span class="btl-hud-info-item" id="btLocateEnvInfo">--</span>
<span class="btl-hud-info-sep">&middot;</span>
<span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span>
<span class="btl-hud-info-sep">&middot;</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 +3008,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 +3018,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 +3157,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 +3614,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 +3692,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 +3709,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 +3750,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 +3777,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 +3793,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 +3824,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 +3854,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 +3947,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 +3957,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 +3966,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 () {
@@ -4784,8 +4978,10 @@
// SDR hardware capabilities // SDR hardware capabilities
const sdrCapabilities = { const sdrCapabilities = {
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 }, 'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 },
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 }, 'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 } 'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 },
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 }
}; };
// Current device list with SDR type info // Current device list with SDR type info
@@ -10012,6 +10208,20 @@
let gpsReconnectTimeout = null; let gpsReconnectTimeout = null;
// GPS subscriber callbacks - modules can register to receive GPS stream data
const gpsStreamSubscribers = [];
function addGpsStreamSubscriber(fn) {
if (!gpsStreamSubscribers.includes(fn)) {
gpsStreamSubscribers.push(fn);
}
}
function removeGpsStreamSubscriber(fn) {
const idx = gpsStreamSubscribers.indexOf(fn);
if (idx !== -1) gpsStreamSubscribers.splice(idx, 1);
}
function startGpsStream() { function startGpsStream() {
if (gpsEventSource) { if (gpsEventSource) {
gpsEventSource.close(); gpsEventSource.close();
@@ -10029,6 +10239,8 @@
gpsLastPosition = data; gpsLastPosition = data;
updateLocationFromGps(data); updateLocationFromGps(data);
} }
// Dispatch to all subscribers (e.g. GPS mode UI)
gpsStreamSubscribers.forEach(fn => fn(data));
} catch (e) { } catch (e) {
console.error('GPS parse error:', e); console.error('GPS parse error:', e);
} }
@@ -10621,10 +10833,7 @@
} }
// Satellite management // Satellite management
let trackedSatellites = [ let trackedSatellites = [];
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
];
function renderSatelliteList() { function renderSatelliteList() {
const list = document.getElementById('satTrackingList'); const list = document.getElementById('satTrackingList');
@@ -10643,14 +10852,27 @@
} }
function toggleSatellite(idx) { function toggleSatellite(idx) {
trackedSatellites[idx].checked = !trackedSatellites[idx].checked; const sat = trackedSatellites[idx];
sat.checked = !sat.checked;
fetch(`/satellite/tracked/${sat.norad}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: sat.checked })
}).catch(() => {});
} }
function removeSatellite(idx) { function removeSatellite(idx) {
if (!trackedSatellites[idx].builtin) { const sat = trackedSatellites[idx];
trackedSatellites.splice(idx, 1); if (sat.builtin) return;
renderSatelliteList(); fetch(`/satellite/tracked/${sat.norad}`, { method: 'DELETE' })
} .then(r => r.json())
.then(data => {
if (data.status === 'success') {
trackedSatellites.splice(idx, 1);
renderSatelliteList();
}
})
.catch(() => {});
} }
function getSelectedSatellites() { function getSelectedSatellites() {
@@ -10686,7 +10908,7 @@
} }
const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l); const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l);
let added = 0; const toAdd = [];
for (let i = 0; i < lines.length; i += 3) { for (let i = 0; i < lines.length; i += 3) {
if (i + 2 < lines.length) { if (i + 2 < lines.length) {
@@ -10696,32 +10918,33 @@
if (line1.startsWith('1 ') && line2.startsWith('2 ')) { if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
const norad = line1.substring(2, 7).trim(); const norad = line1.substring(2, 7).trim();
const id = name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase();
// Check if already exists
if (!trackedSatellites.find(s => s.norad === norad)) { if (!trackedSatellites.find(s => s.norad === norad)) {
trackedSatellites.push({ toAdd.push({ norad_id: norad, name: name, tle1: line1, tle2: line2, enabled: true });
id: id,
name: name,
norad: norad,
builtin: false,
checked: true,
tle: [name, line1, line2]
});
added++;
} }
} }
} }
} }
if (added > 0) { if (toAdd.length === 0) {
renderSatelliteList();
document.getElementById('tleInput').value = '';
closeSatModal();
showInfo(`Added ${added} satellite(s)`);
} else {
alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)'); alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)');
return;
} }
fetch('/satellite/tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toAdd)
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
_loadSatellitesFromAPI();
document.getElementById('tleInput').value = '';
closeSatModal();
showInfo(`Added ${data.added} satellite(s)`);
}
})
.catch(() => alert('Failed to save satellites'));
} }
function fetchCelestrak() { function fetchCelestrak() {
@@ -10737,35 +10960,76 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.status === 'success' && data.satellites) { if (data.status === 'success' && data.satellites) {
let added = 0; const toAdd = data.satellites
data.satellites.forEach(sat => { .filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
const noradStr = String(sat.norad); .map(sat => ({
if (!trackedSatellites.find(s => s.norad === noradStr)) { norad_id: String(sat.norad),
trackedSatellites.push({ name: sat.name,
id: sat.name.replace(/[^a-zA-Z0-9-]/g, '-'), tle1: sat.tle1,
name: sat.name, tle2: sat.tle2,
norad: noradStr, enabled: false
builtin: false, }));
checked: false, // Don't auto-select
tle: [sat.name, sat.tle1, sat.tle2] if (toAdd.length === 0) {
}); status.innerHTML = `<span style="color: var(--accent-green);">All ${data.satellites.length} satellites already tracked</span>`;
added++; return;
}
fetch('/satellite/tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toAdd)
})
.then(r => r.json())
.then(result => {
if (result.status === 'success') {
_loadSatellitesFromAPI();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${result.added} satellites (${data.satellites.length} total in category)</span>`;
} }
})
.catch(() => {
status.innerHTML = `<span style="color: var(--accent-red);">Failed to save satellites</span>`;
}); });
renderSatelliteList();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${added} satellites (${data.satellites.length} total in category)</span>`;
} else { } else {
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`; status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
} }
}) })
.catch(err => { .catch(() => {
status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`; status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`;
}); });
} }
function _loadSatellitesFromAPI() {
fetch('/satellite/tracked')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites) {
trackedSatellites = data.satellites.map(sat => ({
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(),
name: sat.name,
norad: sat.norad_id,
builtin: sat.builtin,
checked: sat.enabled,
tle: sat.tle_line1 ? [sat.name, sat.tle_line1, sat.tle_line2] : null
}));
renderSatelliteList();
}
})
.catch(() => {
// Fallback to hardcoded defaults if API fails
if (trackedSatellites.length === 0) {
trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
];
renderSatelliteList();
}
});
}
// Initialize satellite list when satellite mode is loaded // Initialize satellite list when satellite mode is loaded
function initSatelliteList() { function initSatelliteList() {
renderSatelliteList(); _loadSatellitesFromAPI();
} }
// Utility function // Utility function
+1
View File
@@ -26,6 +26,7 @@
<option value="bleak">Bleak Library</option> <option value="bleak">Bleak Library</option>
<option value="hcitool">hcitool (Linux)</option> <option value="hcitool">hcitool (Linux)</option>
<option value="bluetoothctl">bluetoothctl (Linux)</option> <option value="bluetoothctl">bluetoothctl (Linux)</option>
<option value="ubertooth" id="btScanModeUbertooth" style="display:none;">Ubertooth One</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
+72
View 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 &mdash; 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;">&times;</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">&#127968;</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">&#127795;</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">&#127970;</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
View 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 &mdash; 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>
+4
View File
@@ -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>') }}
+32 -1
View File
@@ -269,12 +269,42 @@
let currentLocationSource = 'local'; let currentLocationSource = 'local';
let agents = []; let agents = [];
const satellites = { let satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' }, 25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
40069: { name: 'METEOR-M2', color: '#9370DB' }, 40069: { name: 'METEOR-M2', color: '#9370DB' },
57166: { name: 'METEOR-M2-3', color: '#ff00ff' } 57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
}; };
const satColors = ['#00ffff', '#9370DB', '#ff00ff', '#00ff00', '#ff6600', '#ffff00', '#ff69b4', '#7b68ee'];
function loadDashboardSatellites() {
fetch('/satellite/tracked?enabled=true')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites && data.satellites.length > 0) {
const newSats = {};
const select = document.getElementById('satSelect');
select.innerHTML = '';
data.satellites.forEach((sat, i) => {
const norad = parseInt(sat.norad_id);
newSats[norad] = {
name: sat.name,
color: satellites[norad]?.color || satColors[i % satColors.length]
};
const opt = document.createElement('option');
opt.value = norad;
opt.textContent = sat.name;
select.appendChild(opt);
});
satellites = newSats;
// Default to ISS if available
if (newSats[25544]) select.value = '25544';
selectedSatellite = parseInt(select.value);
}
})
.catch(() => {});
}
function onSatelliteChange() { function onSatelliteChange() {
const select = document.getElementById('satSelect'); const select = document.getElementById('satSelect');
selectedSatellite = parseInt(select.value); selectedSatellite = parseInt(select.value);
@@ -331,6 +361,7 @@
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadDashboardSatellites();
setupEmbeddedMode(); setupEmbeddedMode();
const usedShared = applySharedObserverLocation(); const usedShared = applySharedObserverLocation();
initGroundMap(); initGroundMap();
+278
View 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()
+8 -5
View File
@@ -313,8 +313,9 @@ class TestDSCDecoder:
def test_decode_mmsi_with_leading_zeros(self, decoder): def test_decode_mmsi_with_leading_zeros(self, decoder):
"""Test MMSI decoding handles leading zeros.""" """Test MMSI decoding handles leading zeros."""
# Coast station: 002320001 # Coast station: 002320001
# 00-23-20-00-01 -> [0, 23, 20, 0, 1] # Padded to 10 digits: 0002320001
symbols = [0, 23, 20, 0, 1] # BCD pairs: 00-02-32-00-01 -> [0, 2, 32, 0, 1]
symbols = [0, 2, 32, 0, 1]
result = decoder._decode_mmsi(symbols) result = decoder._decode_mmsi(symbols)
assert result == '002320001' assert result == '002320001'
@@ -328,14 +329,16 @@ class TestDSCDecoder:
# Symbols > 99 should be treated as 0 # Symbols > 99 should be treated as 0
symbols = [100, 32, 12, 34, 56] symbols = [100, 32, 12, 34, 56]
result = decoder._decode_mmsi(symbols) result = decoder._decode_mmsi(symbols)
# First symbol becomes 00 # First symbol (100) becomes 00, padded result "0032123456",
assert result == '003212345'[-9:] # trim leading pad digit -> "032123456"
assert result == '032123456'
def test_decode_position_northeast(self, decoder): def test_decode_position_northeast(self, decoder):
"""Test position decoding for NE quadrant.""" """Test position decoding for NE quadrant."""
# Quadrant 10 = NE (lat+, lon+) # Quadrant 10 = NE (lat+, lon+)
# Position: 51°30'N, 0°10'E # Position: 51°30'N, 0°10'E
symbols = [10, 51, 30, 0, 10, 0, 0, 0, 0, 0] # lon_deg = symbols[3]*100 + symbols[4] = 0, lon_min = symbols[5] = 10
symbols = [10, 51, 30, 0, 0, 10, 0, 0, 0, 0]
result = decoder._decode_position(symbols) result = decoder._decode_position(symbols)
assert result is not None assert result is not None
+6
View File
@@ -606,6 +606,12 @@ class DeviceAggregator:
return result return result
def get_fingerprint_mac_count(self, fingerprint_id: str) -> int:
"""Return how many distinct device_ids share a fingerprint."""
with self._lock:
device_ids = self._fingerprint_to_devices.get(fingerprint_id)
return len(device_ids) if device_ids else 0
def prune_ring_buffer(self) -> int: def prune_ring_buffer(self) -> int:
"""Prune old observations from ring buffer.""" """Prune old observations from ring buffer."""
return self._ring_buffer.prune_old() return self._ring_buffer.prune_old()
+57
View File
@@ -101,6 +101,7 @@ ADDRESS_TYPE_RANDOM = 'random'
ADDRESS_TYPE_RANDOM_STATIC = 'random_static' ADDRESS_TYPE_RANDOM_STATIC = 'random_static'
ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address
ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address
ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available)
# ============================================================================= # =============================================================================
# PROTOCOL TYPES # PROTOCOL TYPES
@@ -278,3 +279,59 @@ MINOR_WEARABLE = {
0x04: 'Helmet', 0x04: 'Helmet',
0x05: 'Glasses', 0x05: 'Glasses',
} }
# =============================================================================
# BLE APPEARANCE CODES (GAP Appearance values)
# =============================================================================
BLE_APPEARANCE_NAMES: dict[int, str] = {
0: 'Unknown',
64: 'Phone',
128: 'Computer',
192: 'Watch',
193: 'Sports Watch',
256: 'Clock',
320: 'Display',
384: 'Remote Control',
448: 'Eye Glasses',
512: 'Tag',
576: 'Keyring',
640: 'Media Player',
704: 'Barcode Scanner',
768: 'Thermometer',
832: 'Heart Rate Sensor',
896: 'Blood Pressure',
960: 'HID',
961: 'Keyboard',
962: 'Mouse',
963: 'Joystick',
964: 'Gamepad',
965: 'Digitizer Tablet',
966: 'Card Reader',
967: 'Digital Pen',
968: 'Barcode Scanner (HID)',
1024: 'Glucose Monitor',
1088: 'Running Speed Sensor',
1152: 'Cycling',
1216: 'Control Device',
1280: 'Network Device',
1344: 'Sensor',
1408: 'Light Fixture',
1472: 'Fan',
1536: 'HVAC',
1600: 'Access Control',
1664: 'Motorized Device',
1728: 'Power Device',
1792: 'Light Source',
3136: 'Pulse Oximeter',
3200: 'Weight Scale',
3264: 'Personal Mobility',
5184: 'Outdoor Sports Activity',
}
def get_appearance_name(code: int | None) -> str | None:
"""Look up a human-readable name for a BLE appearance code."""
if code is None:
return None
return BLE_APPEARANCE_NAMES.get(code)
+7 -2
View File
@@ -12,6 +12,7 @@ from typing import Optional
from .constants import ( from .constants import (
ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_UUID,
) )
@@ -46,10 +47,14 @@ def generate_device_key(
if identity_address: if identity_address:
return f"id:{identity_address.upper()}" return f"id:{identity_address.upper()}"
# Priority 2: Use public or random_static addresses directly # Priority 2: Use public or random_static addresses directly (not platform UUIDs)
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC): if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
return f"mac:{address.upper()}" return f"mac:{address.upper()}"
# Priority 2b: CoreBluetooth UUIDs are stable per-system, use as identifier
if address_type == ADDRESS_TYPE_UUID:
return f"uuid:{address.upper()}"
# Priority 3: Generate fingerprint hash for random addresses # Priority 3: Generate fingerprint hash for random addresses
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids) return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
@@ -102,7 +107,7 @@ def is_randomized_mac(address_type: str) -> bool:
Returns: Returns:
True if the address is randomized, False otherwise. True if the address is randomized, False otherwise.
""" """
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC) return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_UUID)
def extract_key_type(device_key: str) -> str: def extract_key_type(device_key: str) -> str:
+8 -1
View File
@@ -24,8 +24,12 @@ from .constants import (
BLUETOOTHCTL_TIMEOUT, BLUETOOTHCTL_TIMEOUT,
ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM, ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_UUID,
MANUFACTURER_NAMES, MANUFACTURER_NAMES,
) )
# CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits
_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')
from .models import BTObservation from .models import BTObservation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -132,7 +136,10 @@ class BleakScanner:
"""Convert bleak device to BTObservation.""" """Convert bleak device to BTObservation."""
# Determine address type from address format # Determine address type from address format
address_type = ADDRESS_TYPE_PUBLIC address_type = ADDRESS_TYPE_PUBLIC
if device.address and ':' in device.address: if device.address and _CB_UUID_RE.match(device.address):
# macOS CoreBluetooth returns a platform UUID instead of a real MAC
address_type = ADDRESS_TYPE_UUID
elif device.address and ':' in device.address:
# Check if first byte indicates random address # Check if first byte indicates random address
first_byte = int(device.address.split(':')[0], 16) first_byte = int(device.address.split(':')[0], 16)
if (first_byte & 0xC0) == 0xC0: # Random static if (first_byte & 0xC0) == 0xC0: # Random static
+198
View 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
+32
View File
@@ -18,6 +18,7 @@ from .constants import (
RANGE_UNKNOWN, RANGE_UNKNOWN,
PROTOCOL_BLE, PROTOCOL_BLE,
PROXIMITY_UNKNOWN, PROXIMITY_UNKNOWN,
get_appearance_name,
) )
# Import tracker types (will be available after tracker_signatures module loads) # Import tracker types (will be available after tracker_signatures module loads)
@@ -165,6 +166,10 @@ class BTDeviceAggregate:
risk_score: float = 0.0 # 0.0 to 1.0 risk_score: float = 0.0 # 0.0 to 1.0
risk_factors: list[str] = field(default_factory=list) risk_factors: list[str] = field(default_factory=list)
# IRK (Identity Resolving Key) from paired device database
irk_hex: Optional[str] = None # 32-char hex if known
irk_source_name: Optional[str] = None # Name from paired DB
# Payload fingerprint (survives MAC randomization) # Payload fingerprint (survives MAC randomization)
payload_fingerprint_id: Optional[str] = None payload_fingerprint_id: Optional[str] = None
payload_fingerprint_stability: float = 0.0 payload_fingerprint_stability: float = 0.0
@@ -296,6 +301,11 @@ class BTDeviceAggregate:
'risk_factors': self.risk_factors, 'risk_factors': self.risk_factors,
}, },
# IRK
'has_irk': self.irk_hex is not None,
'irk_hex': self.irk_hex,
'irk_source_name': self.irk_source_name,
# Fingerprint # Fingerprint
'fingerprint': { 'fingerprint': {
'id': self.payload_fingerprint_id, 'id': self.payload_fingerprint_id,
@@ -319,15 +329,32 @@ class BTDeviceAggregate:
'rssi_current': self.rssi_current, 'rssi_current': self.rssi_current,
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None, 'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None, 'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
'rssi_min': self.rssi_min,
'rssi_max': self.rssi_max,
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
'range_band': self.range_band, 'range_band': self.range_band,
'proximity_band': self.proximity_band, 'proximity_band': self.proximity_band,
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, 'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
'distance_confidence': round(self.distance_confidence, 2), 'distance_confidence': round(self.distance_confidence, 2),
'is_randomized_mac': self.is_randomized_mac, 'is_randomized_mac': self.is_randomized_mac,
'last_seen': self.last_seen.isoformat(), 'last_seen': self.last_seen.isoformat(),
'first_seen': self.first_seen.isoformat(),
'age_seconds': self.age_seconds, 'age_seconds': self.age_seconds,
'duration_seconds': self.duration_seconds,
'seen_count': self.seen_count, 'seen_count': self.seen_count,
'seen_rate': round(self.seen_rate, 2),
'tx_power': self.tx_power,
'manufacturer_id': self.manufacturer_id,
'appearance': self.appearance,
'appearance_name': get_appearance_name(self.appearance),
'is_connectable': self.is_connectable,
'service_uuids': self.service_uuids,
'service_data': {k: v.hex() for k, v in self.service_data.items()},
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
'heuristic_flags': self.heuristic_flags, 'heuristic_flags': self.heuristic_flags,
'is_persistent': self.is_persistent,
'is_beacon_like': self.is_beacon_like,
'is_strong_stable': self.is_strong_stable,
'in_baseline': self.in_baseline, 'in_baseline': self.in_baseline,
'seen_before': self.seen_before, 'seen_before': self.seen_before,
# Tracker info for list view # Tracker info for list view
@@ -336,7 +363,12 @@ class BTDeviceAggregate:
'tracker_name': self.tracker_name, 'tracker_name': self.tracker_name,
'tracker_confidence': self.tracker_confidence, 'tracker_confidence': self.tracker_confidence,
'tracker_confidence_score': round(self.tracker_confidence_score, 2), 'tracker_confidence_score': round(self.tracker_confidence_score, 2),
'tracker_evidence': self.tracker_evidence,
'risk_score': round(self.risk_score, 2), 'risk_score': round(self.risk_score, 2),
'risk_factors': self.risk_factors,
'has_irk': self.irk_hex is not None,
'irk_hex': self.irk_hex,
'irk_source_name': self.irk_source_name,
'fingerprint_id': self.payload_fingerprint_id, 'fingerprint_id': self.payload_fingerprint_id,
} }
+113 -9
View File
@@ -24,7 +24,9 @@ from .constants import (
) )
from .dbus_scanner import DBusScanner from .dbus_scanner import DBusScanner
from .fallback_scanner import FallbackScanner from .fallback_scanner import FallbackScanner
from .ubertooth_scanner import UbertoothScanner
from .heuristics import HeuristicsEngine from .heuristics import HeuristicsEngine
from .irk_extractor import get_paired_irks
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -57,6 +59,7 @@ class BluetoothScanner:
# Scanner backends # Scanner backends
self._dbus_scanner: Optional[DBusScanner] = None self._dbus_scanner: Optional[DBusScanner] = None
self._fallback_scanner: Optional[FallbackScanner] = None self._fallback_scanner: Optional[FallbackScanner] = None
self._ubertooth_scanner: Optional[UbertoothScanner] = None
self._active_backend: Optional[str] = None self._active_backend: Optional[str] = None
# Event queue for SSE streaming # Event queue for SSE streaming
@@ -66,7 +69,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
@@ -113,6 +116,8 @@ class BluetoothScanner:
if mode == 'dbus': if mode == 'dbus':
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold) started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
elif mode == 'ubertooth':
started, backend_used = self._start_ubertooth()
# Fallback: try non-DBus methods if DBus failed or wasn't requested # Fallback: try non-DBus methods if DBus failed or wasn't requested
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')): if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
@@ -168,6 +173,18 @@ class BluetoothScanner:
logger.warning(f"DBus scanner failed: {e}") logger.warning(f"DBus scanner failed: {e}")
return False, None return False, None
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
"""Start Ubertooth One scanner."""
try:
self._ubertooth_scanner = UbertoothScanner(
on_observation=self._handle_observation,
)
if self._ubertooth_scanner.start():
return True, 'ubertooth'
except Exception as e:
logger.warning(f"Ubertooth scanner failed: {e}")
return False, None
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]: def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
"""Start fallback scanner.""" """Start fallback scanner."""
try: try:
@@ -204,6 +221,10 @@ class BluetoothScanner:
self._fallback_scanner.stop() self._fallback_scanner.stop()
self._fallback_scanner = None self._fallback_scanner = None
if self._ubertooth_scanner:
self._ubertooth_scanner.stop()
self._ubertooth_scanner = None
# Update status # Update status
self._status.is_scanning = False self._status.is_scanning = False
self._active_backend = None self._active_backend = None
@@ -216,6 +237,47 @@ class BluetoothScanner:
logger.info("Bluetooth scan stopped") logger.info("Bluetooth scan stopped")
def _match_irk(self, device: BTDeviceAggregate) -> None:
"""Check if a device address resolves against any paired IRK."""
if device.irk_hex is not None:
return # Already matched
address = device.address
if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32):
return
# Only attempt RPA resolution on 6-byte addresses
addr_clean = address.replace(':', '').replace('-', '')
if len(addr_clean) != 12:
return
try:
paired = get_paired_irks()
except Exception:
return
if not paired:
return
try:
from utils.bt_locate import resolve_rpa
except ImportError:
return
for entry in paired:
irk_hex = entry.get('irk_hex', '')
if not irk_hex or len(irk_hex) != 32:
continue
try:
irk = bytes.fromhex(irk_hex)
if resolve_rpa(irk, address):
device.irk_hex = irk_hex
device.irk_source_name = entry.get('name')
logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}")
return
except Exception:
continue
def _handle_observation(self, observation: BTObservation) -> None: def _handle_observation(self, observation: BTObservation) -> None:
"""Handle incoming observation from scanner backend.""" """Handle incoming observation from scanner backend."""
try: try:
@@ -225,20 +287,35 @@ class BluetoothScanner:
# Evaluate heuristics # Evaluate heuristics
self._heuristics.evaluate(device) self._heuristics.evaluate(device)
# Check for IRK match
self._match_irk(device)
# Update device count # Update device count
with self._lock: with self._lock:
self._status.devices_found = self._aggregator.device_count self._status.devices_found = self._aggregator.device_count
# Build summary with MAC cluster count
summary = device.to_summary_dict()
if device.payload_fingerprint_id:
summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count(
device.payload_fingerprint_id
)
else:
summary['mac_cluster_count'] = 0
# Queue event # Queue event
self._queue_event({ self._queue_event({
'type': 'device', 'type': 'device',
'action': 'update', 'action': 'update',
'device': device.to_summary_dict(), 'device': summary,
}) })
# 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 +445,40 @@ 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)
or (self._ubertooth_scanner and self._ubertooth_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
View 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
+132
View File
@@ -531,6 +531,30 @@ def init_db() -> None:
ON push_payloads(agent_id, received_at) ON push_payloads(agent_id, received_at)
''') ''')
# Tracked satellites table for persistent satellite management
conn.execute('''
CREATE TABLE IF NOT EXISTS tracked_satellites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
norad_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
tle_line1 TEXT,
tle_line2 TEXT,
enabled BOOLEAN DEFAULT 1,
builtin BOOLEAN DEFAULT 0,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Seed builtin satellites if not already present
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1)
''')
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
''')
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
@@ -2170,3 +2194,111 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
''', (f'-{max_age_hours} hours',)) ''', (f'-{max_age_hours} hours',))
return cursor.rowcount return cursor.rowcount
# =============================================================================
# Tracked Satellites Functions
# =============================================================================
def get_tracked_satellites(enabled_only: bool = False) -> list[dict]:
"""Return all tracked satellites, optionally filtered to enabled only."""
with get_db() as conn:
if enabled_only:
rows = conn.execute(
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
'FROM tracked_satellites WHERE enabled = 1 ORDER BY builtin DESC, name'
).fetchall()
else:
rows = conn.execute(
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
'FROM tracked_satellites ORDER BY builtin DESC, name'
).fetchall()
return [
{
'norad_id': r[0],
'name': r[1],
'tle_line1': r[2],
'tle_line2': r[3],
'enabled': bool(r[4]),
'builtin': bool(r[5]),
'added_at': r[6],
}
for r in rows
]
def add_tracked_satellite(
norad_id: str,
name: str,
tle_line1: str | None = None,
tle_line2: str | None = None,
enabled: bool = True,
builtin: bool = False,
) -> bool:
"""Insert a tracked satellite. Returns True if inserted, False if duplicate."""
with get_db() as conn:
try:
conn.execute(
'INSERT OR IGNORE INTO tracked_satellites '
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
'VALUES (?, ?, ?, ?, ?, ?)',
(str(norad_id), name, tle_line1, tle_line2, int(enabled), int(builtin)),
)
return conn.total_changes > 0
except sqlite3.Error as e:
logger.error(f"Error adding tracked satellite {norad_id}: {e}")
return False
def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int:
"""Insert many tracked satellites at once. Returns count of newly inserted."""
added = 0
with get_db() as conn:
for sat in satellites_list:
try:
cursor = conn.execute(
'INSERT OR IGNORE INTO tracked_satellites '
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
'VALUES (?, ?, ?, ?, ?, ?)',
(
str(sat['norad_id']),
sat['name'],
sat.get('tle_line1'),
sat.get('tle_line2'),
int(sat.get('enabled', True)),
int(sat.get('builtin', False)),
),
)
if cursor.rowcount > 0:
added += 1
except (sqlite3.Error, KeyError) as e:
logger.warning(f"Error bulk-adding satellite: {e}")
return added
def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
"""Toggle enabled state for a tracked satellite."""
with get_db() as conn:
cursor = conn.execute(
'UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?',
(int(enabled), str(norad_id)),
)
return cursor.rowcount > 0
def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
"""Delete a tracked satellite by NORAD ID. Refuses to delete builtins."""
with get_db() as conn:
row = conn.execute(
'SELECT builtin FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
).fetchone()
if row is None:
return False, 'Satellite not found'
if row[0]:
return False, 'Cannot remove builtin satellite'
conn.execute(
'DELETE FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
)
return True, 'Removed'
+17 -2
View File
@@ -264,6 +264,20 @@ class DSCDecoder:
symbol_value = self._bits_to_symbol(symbol_bits) symbol_value = self._bits_to_symbol(symbol_bits)
symbols.append(symbol_value) symbols.append(symbol_value)
# Strip phasing sequence (RX/DX symbols 120-126) from the
# start of the message. Per ITU-R M.493, after the dot pattern
# there are 7 phasing symbols before the format specifier.
msg_start = 0
for i, sym in enumerate(symbols):
if 120 <= sym <= 126:
msg_start = i + 1
else:
break
symbols = symbols[msg_start:]
if len(symbols) < 5:
return None
# Look for EOS (End of Sequence) - symbol 127 # Look for EOS (End of Sequence) - symbol 127
eos_found = False eos_found = False
eos_index = -1 eos_index = -1
@@ -404,9 +418,10 @@ class DSCDecoder:
digits.append(f'{sym:02d}') digits.append(f'{sym:02d}')
mmsi = ''.join(digits) mmsi = ''.join(digits)
# MMSI is 9 digits, might need to trim leading zero # MMSI is 9 digits - trim the leading digit from the 10-digit
# BCD result since the first symbol's high digit is always 0
if len(mmsi) > 9: if len(mmsi) > 9:
mmsi = mmsi[-9:] mmsi = mmsi[1:]
return mmsi.zfill(9) return mmsi.zfill(9)
+361 -30
View File
@@ -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,9 +477,187 @@ 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:
return client.position return client.position
return None return None
# ============================================
# GPS device detection and gpsd auto-start
# ============================================
_gpsd_process: 'subprocess.Popen | None' = None
_gpsd_process_lock = threading.Lock()
def detect_gps_devices() -> list[dict]:
"""
Detect connected GPS serial devices.
Returns list of dicts with 'path' and 'description' keys.
"""
import glob
import os
import platform
devices: list[dict] = []
system = platform.system()
if system == 'Linux':
# Common USB GPS device paths
patterns = ['/dev/ttyUSB*', '/dev/ttyACM*']
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
desc = _describe_device_linux(path)
devices.append({'path': path, 'description': desc})
# Also check /dev/serial/by-id for descriptive names
serial_dir = '/dev/serial/by-id'
if os.path.isdir(serial_dir):
for name in sorted(os.listdir(serial_dir)):
full = os.path.join(serial_dir, name)
real = os.path.realpath(full)
# Skip if we already found this device
if any(d['path'] == real for d in devices):
# Update description with the more descriptive name
for d in devices:
if d['path'] == real:
d['description'] = name
continue
devices.append({'path': real, 'description': name})
elif system == 'Darwin':
# macOS: USB serial devices (prefer cu. over tty. for outgoing)
patterns = ['/dev/cu.usbmodem*', '/dev/cu.usbserial*']
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
desc = _describe_device_macos(path)
devices.append({'path': path, 'description': desc})
# Sort: devices with GPS-related descriptions first
gps_keywords = ('gps', 'gnss', 'u-blox', 'ublox', 'nmea', 'sirf', 'navigation')
devices.sort(key=lambda d: (
0 if any(k in d['description'].lower() for k in gps_keywords) else 1
))
return devices
def _describe_device_linux(path: str) -> str:
"""Get a human-readable description of a Linux serial device."""
import os
basename = os.path.basename(path)
# Try to read from sysfs
try:
# /sys/class/tty/ttyUSB0/device/../product
sysfs = f'/sys/class/tty/{basename}/device/../product'
if os.path.exists(sysfs):
with open(sysfs) as f:
return f.read().strip()
except Exception:
pass
return basename
def _describe_device_macos(path: str) -> str:
"""Get a description of a macOS serial device."""
import os
return os.path.basename(path)
def is_gpsd_running(host: str = 'localhost', port: int = 2947) -> bool:
"""Check if gpsd is reachable."""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1.0)
sock.connect((host, port))
sock.close()
return True
except Exception:
return False
def start_gpsd_daemon(device_path: str, host: str = 'localhost',
port: int = 2947) -> tuple[bool, str]:
"""
Start gpsd daemon pointing at the given device.
Returns (success, message) tuple.
"""
import shutil
import subprocess
global _gpsd_process
with _gpsd_process_lock:
# Already running?
if is_gpsd_running(host, port):
return True, 'gpsd already running'
gpsd_bin = shutil.which('gpsd')
if not gpsd_bin:
return False, 'gpsd not installed'
# Stop any existing managed process
stop_gpsd_daemon()
try:
import os
if not os.path.exists(device_path):
return False, f'Device {device_path} not found'
cmd = [gpsd_bin, '-N', '-n', '-S', str(port), device_path]
logger.info(f"Starting gpsd: {' '.join(cmd)}")
print(f"[GPS] Starting gpsd: {' '.join(cmd)}", flush=True)
_gpsd_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
# Give gpsd a moment to start
import time
time.sleep(1.5)
if _gpsd_process.poll() is not None:
stderr = ''
if _gpsd_process.stderr:
stderr = _gpsd_process.stderr.read().decode('utf-8', errors='ignore').strip()
msg = f'gpsd exited with code {_gpsd_process.returncode}'
if stderr:
msg += f': {stderr}'
return False, msg
# Verify it's listening
if is_gpsd_running(host, port):
return True, f'gpsd started on {device_path}'
else:
return False, 'gpsd started but not accepting connections'
except Exception as e:
logger.error(f"Failed to start gpsd: {e}")
return False, str(e)
def stop_gpsd_daemon() -> None:
"""Stop the managed gpsd daemon process."""
global _gpsd_process
with _gpsd_process_lock:
if _gpsd_process and _gpsd_process.poll() is None:
try:
_gpsd_process.terminate()
_gpsd_process.wait(timeout=3.0)
except Exception:
try:
_gpsd_process.kill()
except Exception:
pass
logger.info("Stopped gpsd daemon")
print("[GPS] Stopped gpsd daemon", flush=True)
_gpsd_process = None
+90
View File
@@ -2,11 +2,14 @@ from __future__ import annotations
import atexit import atexit
import logging import logging
import os
import platform
import signal import signal
import subprocess import subprocess
import re import re
import threading import threading
import time import time
from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from .dependencies import check_tool from .dependencies import check_tool
@@ -117,6 +120,93 @@ def cleanup_stale_processes() -> None:
pass pass
_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid'
def write_dump1090_pid(pid: int) -> None:
"""Write the PID of an app-spawned dump1090 process to a PID file."""
try:
_DUMP1090_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
_DUMP1090_PID_FILE.write_text(str(pid))
logger.debug(f"Wrote dump1090 PID file: {pid}")
except OSError as e:
logger.warning(f"Failed to write dump1090 PID file: {e}")
def clear_dump1090_pid() -> None:
"""Remove the dump1090 PID file."""
try:
_DUMP1090_PID_FILE.unlink(missing_ok=True)
logger.debug("Cleared dump1090 PID file")
except OSError as e:
logger.warning(f"Failed to clear dump1090 PID file: {e}")
def _is_dump1090_process(pid: int) -> bool:
"""Check if the given PID is actually a dump1090/readsb process."""
try:
if platform.system() == 'Linux':
cmdline_path = Path(f'/proc/{pid}/cmdline')
if cmdline_path.exists():
cmdline = cmdline_path.read_bytes().replace(b'\x00', b' ').decode('utf-8', errors='ignore')
return 'dump1090' in cmdline or 'readsb' in cmdline
# macOS or fallback
result = subprocess.run(
['ps', '-p', str(pid), '-o', 'comm='],
capture_output=True, text=True, timeout=5
)
comm = result.stdout.strip()
return 'dump1090' in comm or 'readsb' in comm
except Exception:
return False
def cleanup_stale_dump1090() -> None:
"""Kill a stale app-spawned dump1090 using the PID file.
Safe no-op if no PID file exists, process is dead, or PID was reused
by another program.
"""
if not _DUMP1090_PID_FILE.exists():
return
try:
pid = int(_DUMP1090_PID_FILE.read_text().strip())
except (ValueError, OSError) as e:
logger.warning(f"Invalid dump1090 PID file: {e}")
clear_dump1090_pid()
return
# Verify this PID is still a dump1090/readsb process
if not _is_dump1090_process(pid):
logger.debug(f"PID {pid} is not dump1090/readsb (dead or reused), removing stale PID file")
clear_dump1090_pid()
return
# Kill the process group
logger.info(f"Killing stale app-spawned dump1090 (PID {pid})")
try:
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
# Brief wait for graceful shutdown
for _ in range(10):
try:
os.kill(pid, 0) # Check if still alive
time.sleep(0.2)
except OSError:
break
else:
# Still alive, force kill
try:
os.killpg(pgid, signal.SIGKILL)
except OSError:
pass
except OSError as e:
logger.debug(f"Error killing stale dump1090 PID {pid}: {e}")
clear_dump1090_pid()
def is_valid_mac(mac: str | None) -> bool: def is_valid_mac(mac: str | None) -> bool:
"""Validate MAC address format.""" """Validate MAC address format."""
if not mac: if not mac: