Compare commits

...

13 Commits

Author SHA1 Message Date
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
Smittix 243a0f0e7f chore: Bump version to v2.16.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:32:15 +00:00
Smittix 7c3ec9e920 chore: commit all changes and remove large IQ captures from tracking
Add .gitignore entry for data/subghz/captures/ to prevent large
IQ recording files from being committed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:30:37 +00:00
Smittix 4639146f05 fix: Remove incomplete MLAT feature causing ImportError on startup
The partially-added MLAT support was out of sync between config and
routes, causing an ImportError when importing adsb_bp. Remove all MLAT
additions from config, template UI/JS, and docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:55:21 +00:00
Smittix a354fee792 fix: Resolve listening post audio stuttering introduced in v2.15.0
Throttle audio waterfall rendering (50ms→200ms), eliminate per-frame
Array.from() allocation, drain stale pipe buffer before streaming,
increase chunk size to 8192, and remove debug logging from animation
hot paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:24:51 +00:00
74 changed files with 16221 additions and 1151 deletions
+3
View File
@@ -58,6 +58,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/
# SDR capture files (large IQ recordings)
data/subghz/captures/
# Env files
.env
.env.*
+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
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:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"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
- **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
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
- **Listening Post** - Frequency scanner with audio monitoring
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
- **Satellite Tracking** - Pass prediction using TLE data
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
- **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)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration
- **Spy Stations** - Number stations and diplomatic HF network database
@@ -224,7 +228,7 @@ This project was developed using AI as a coding partner, combining human directi
## License
MIT License - see [LICENSE](LICENSE)
Apache 2.0 License - see [LICENSE](LICENSE)
## Author
@@ -238,6 +242,8 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[dump1090](https://github.com/flightaware/dump1090) |
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
[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/) |
[Leaflet.js](https://leafletjs.com/) |
[SatDump](https://github.com/SatDump/SatDump) |
@@ -252,3 +258,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
+40 -8
View File
@@ -182,6 +182,10 @@ dmr_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -643,8 +647,27 @@ def export_bluetooth() -> Response:
})
@app.route('/health')
def health_check() -> Response:
def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode != 'idle'
except Exception:
return False
def _get_dmr_active() -> bool:
"""Check if Digital Voice decoder has an active process."""
try:
from routes import dmr as dmr_module
proc = dmr_module.dmr_dsd_process
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
except Exception:
return False
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
return jsonify({
@@ -658,11 +681,12 @@ def health_check() -> Response:
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
},
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': _get_dmr_active(),
'subghz': _get_subghz_active(),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
@@ -692,7 +716,8 @@ def kill_all() -> Response:
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
]
for proc in processes_to_kill:
@@ -761,6 +786,13 @@ def kill_all() -> Response:
except Exception:
pass
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
+23 -1
View File
@@ -7,10 +7,22 @@ import os
import sys
# Application version
VERSION = "2.15.0"
VERSION = "2.16.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.16.0",
"date": "February 2026",
"highlights": [
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
"Weather satellite auto-scheduler with polar plot and ground track map",
"SatDump support for local (non-Docker) installs via setup.sh",
"Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)",
]
},
{
"version": "2.15.0",
"date": "February 2026",
@@ -227,6 +239,16 @@ WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
+103
View File
@@ -16,6 +16,14 @@ Complete feature list for all modules.
- **Doorbells, remotes, and IoT devices**
- **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
- **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
- **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
- **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
- **Device type breakdown** chart
## BT Locate (SAR Bluetooth Device Location)
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
### Core Features
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
- **Distance estimation** - Log-distance path loss model with environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.2) - Typical outdoor environment
- **Indoor** (n=3.0) - Indoor with walls and obstacles
### Map & Trail
- Interactive Leaflet map with GPS trail visualization
- Trail points color-coded by proximity band
- Polyline connecting detection points for path visualization
- Supports user-configured tile providers
### Requirements
- Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates)
## GPS Mode
Real-time GPS position tracking with live map visualization.
### Features
- **Live position tracking** - Real-time latitude, longitude, altitude display
- **Interactive map** - Current position on Leaflet map with track history
- **Speed and heading** - Real-time speed (km/h) and compass heading
- **Satellite info** - Number of satellites in view and fix quality
- **Track recording** - Record GPS tracks with export capability
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
### Requirements
- USB GPS receiver connected via gpsd
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
## TSCM Counter-Surveillance Mode
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
+204 -76
View File
@@ -57,92 +57,120 @@ INTERCEPT automatically detects known trackers:
- Samsung SmartTag
- 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)
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
## Satellite Mode
@@ -163,6 +191,106 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
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)
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 class="hero-stats">
<div class="stat">
<span class="stat-value">15+</span>
<span class="stat-value">20+</span>
<span class="stat-label">Modes</span>
</div>
<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>
</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-icon">📻</div>
<h3>Listening Post</h3>
@@ -101,6 +107,18 @@
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
</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-icon">🛡️</div>
<h3>TSCM</h3>
@@ -143,11 +161,47 @@
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</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-icon">🖼️</div>
<h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</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>
</section>
@@ -194,6 +248,10 @@
<img src="images/ais.png" alt="AIS Vessel Tracking">
<span class="screenshot-label">AIS Vessel Tracking</span>
</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>
</section>
+199 -199
View File
@@ -838,15 +838,15 @@ class ModeManager:
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
elif mode == 'aprs':
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'listening_post':
data['data'] = {
'activity': getattr(self, 'listening_post_activity', []),
@@ -1105,24 +1105,24 @@ class ModeManager:
self.wifi_clients.clear()
elif mode == 'bluetooth':
self.bluetooth_devices.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'dsc':
# Clear DSC data
if hasattr(self, 'dsc_messages'):
@@ -1542,10 +1542,10 @@ class ModeManager:
def _start_wifi(self, params: dict) -> dict:
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
interface = params.get('interface')
channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
# Handle quick scan - returns results synchronously
if scan_type == 'quick':
@@ -1574,21 +1574,21 @@ class ModeManager:
else:
scan_band = 'all'
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
# Start thread to sync data to agent's dictionaries
thread = threading.Thread(
target=self._wifi_data_sync,
@@ -1607,12 +1607,12 @@ class ModeManager:
else:
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
def _wifi_data_sync(self, scanner):
"""Sync WiFi scanner data to agent's data structures."""
@@ -1646,14 +1646,14 @@ class ModeManager:
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
self._wifi_scanner_instance.stop_deep_scan()
def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
if not interface:
return {'status': 'error', 'message': 'WiFi interface required'}
@@ -1680,23 +1680,23 @@ class ModeManager:
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
if gps_manager.is_running:
cmd.append('--gpsd')
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
cmd.append(interface)
try:
@@ -2022,7 +2022,7 @@ class ModeManager:
'agent_gps': gps_manager.position
}
scanner.set_on_device_updated(on_device_updated)
scanner.add_device_callback(on_device_updated)
# Start scanning
if scanner.start_scan(mode=mode_param, duration_s=duration):
@@ -3148,21 +3148,21 @@ class ModeManager:
self.tscm_baseline = {}
if not hasattr(self, 'tscm_anomalies'):
self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
# Get params for what to scan
scan_wifi = params.get('wifi', True)
scan_bt = params.get('bluetooth', True)
scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
# Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id')
@@ -3170,11 +3170,11 @@ class ModeManager:
started_scans = []
# Start the combined TSCM scanner thread using existing Intercept functions
thread = threading.Thread(
target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True
)
thread = threading.Thread(
target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True
)
thread.start()
self.output_threads['tscm'] = thread
@@ -3193,9 +3193,9 @@ class ModeManager:
'scanning': started_scans
}
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None, sweep_type: str | None = None):
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None, sweep_type: str | None = None):
"""Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly:
@@ -3208,20 +3208,20 @@ class ModeManager:
stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
sweep_ranges = None
# Load baseline if specified (same as local mode)
baseline = None
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
sweep_ranges = None
# Load baseline if specified (same as local mode)
baseline = None
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
baseline = get_tscm_baseline(baseline_id)
if baseline:
@@ -3242,9 +3242,9 @@ class ModeManager:
self._tscm_correlation = None
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
last_rf_scan = 0
rf_scan_interval = 30
@@ -3290,63 +3290,63 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
# Bluetooth scan using Intercept's function (same as local mode)
if scan_bt:
@@ -3380,18 +3380,18 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.bluetooth_devices[mac] = enriched
except Exception as e:
@@ -3402,11 +3402,11 @@ class ModeManager:
try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals(
sdr_device,
stop_check=agent_stop_check,
sweep_ranges=sweep_ranges
)
rf_signals = _scan_rf_signals(
sdr_device,
stop_check=agent_stop_check,
sweep_ranges=sweep_ranges
)
# Analyze each RF signal like local mode does
analyzed_signals = []
@@ -3426,17 +3426,17 @@ class ModeManager:
analyzed['reasons'] = classification.get('reasons', [])
# Use correlation engine for scoring (same as local mode)
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
analyzed['is_threat'] = is_threat
analyzed_signals.append(analyzed)
+3 -3
View File
@@ -1,10 +1,10 @@
[project]
name = "intercept"
version = "2.15.0"
version = "2.16.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
license = {text = "Apache-2.0"}
authors = [
{name = "Intercept Contributors"}
]
@@ -14,7 +14,7 @@ classifiers = [
"Environment :: Web Environment",
"Framework :: Flask",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
+3
View File
@@ -32,6 +32,9 @@ scapy>=2.4.5
# QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
cryptography>=41.0.0
# Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0
# pytest-cov>=4.0.0
+4
View File
@@ -32,6 +32,8 @@ def register_blueprints(app):
from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
from .subghz import subghz_bp
from .bt_locate import bt_locate_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -63,6 +65,8 @@ def register_blueprints(app):
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
# Initialize TSCM state with queue and lock from app
import app as app_module
+101 -79
View File
@@ -19,15 +19,16 @@ from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
@@ -72,14 +73,19 @@ def find_multimon_ng() -> Optional[str]:
return shutil.which('multimon-ng')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
# Path to direwolf config file
@@ -1414,19 +1420,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
@aprs_bp.route('/tools')
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
return jsonify({
'rtl_fm': has_rtl_fm,
'direwolf': has_direwolf,
'multimon_ng': has_multimon,
'ready': has_rtl_fm and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
})
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({
'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm,
'direwolf': has_direwolf,
'multimon_ng': has_multimon,
'ready': has_fm_demod and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
})
@aprs_bp.route('/status')
@@ -1467,20 +1476,12 @@ def start_aprs() -> Response:
'message': 'APRS decoder already running'
}), 409
# Check for required tools
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400
@@ -1488,12 +1489,31 @@ def start_aprs() -> Response:
data = request.json or {}
# Validate inputs
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else:
if find_rx_fm() is None:
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs')
@@ -1525,28 +1545,29 @@ def start_aprs() -> Response:
aprs_last_packet_time = None
aprs_stations = {}
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
freq_hz = f"{float(frequency)}M"
rtl_cmd = [
rtl_fm_path,
'-f', freq_hz,
'-M', 'nfm', # Narrowband FM for APRS
'-s', '22050', # Sample rate matching direwolf -r 22050
'-E', 'dc', # Enable DC blocking filter for cleaner audio
'-A', 'fast', # Fast AGC for packet bursts
'-d', str(device),
]
# Gain: 0 means auto, otherwise set specific gain
if gain and str(gain) != '0':
rtl_cmd.extend(['-g', str(gain)])
# PPM frequency correction
if ppm and str(ppm) != '0':
rtl_cmd.extend(['-p', str(ppm)])
# Output raw audio to stdout
rtl_cmd.append('-')
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=float(frequency),
sample_rate=22050,
gain=float(gain) if gain and str(gain) != '0' else None,
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
# APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command
if direwolf_path:
@@ -1669,13 +1690,14 @@ def start_aprs() -> Response:
)
thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'decoder': decoder_name
})
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'sdr_type': sdr_type.value,
'decoder': decoder_name
})
except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}")
+19 -13
View File
@@ -37,11 +37,17 @@ def find_rtl_fm():
return shutil.which('rtl_fm')
def find_ffmpeg():
return shutil.which('ffmpeg')
def kill_audio_processes():
def find_ffmpeg():
return shutil.which('ffmpeg')
def _rtl_fm_demod_mode(modulation):
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def kill_audio_processes():
"""Kill any running audio processes."""
global audio_process, rtl_process
@@ -104,14 +110,14 @@ def start_audio_stream(config):
freq_hz = int(freq * 1e6)
rtl_cmd = [
rtl_fm,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
rtl_cmd = [
rtl_fm,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
'-l', str(squelch),
]
+177 -177
View File
@@ -7,40 +7,40 @@ aggregation, and heuristics.
from __future__ import annotations
import csv
import io
import json
import logging
import threading
import time
import csv
import io
import json
import logging
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request, session
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
TrackerType,
TrackerConfidence,
get_tracker_engine,
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
logger = logging.getLogger('intercept.bluetooth_v2')
# Blueprint
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
# =============================================================================
# DATABASE FUNCTIONS
@@ -172,20 +172,20 @@ def get_all_baselines() -> list[dict]:
return [dict(row) for row in cursor]
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
# =============================================================================
@@ -206,7 +206,7 @@ def get_capabilities():
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
def start_scan():
def start_scan():
"""
Start Bluetooth scanning.
@@ -236,42 +236,42 @@ def start_scan():
# Get scanner instance
scanner = get_bluetooth_scanner(adapter_id)
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if scanner._on_device_updated is None:
scanner._on_device_updated = _handle_seen_before
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if _handle_seen_before not in scanner._on_device_updated_callbacks:
scanner.add_device_callback(_handle_seen_before)
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists
baseline_id = get_active_baseline_id()
if baseline_id:
device_ids = get_baseline_device_ids(baseline_id)
@@ -896,15 +896,15 @@ def stream_events():
else:
return event_type, event
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
return Response(
event_generator(),
@@ -988,34 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
devices = scanner.get_devices()
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count,
'range_band': device.range_band,
@@ -1229,38 +1229,38 @@ def get_device_timeseries(device_key: str):
return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
return 'wearable'
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
@@ -1269,41 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
return 'computer'
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
return 'peripheral'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
# Check by class of device
if device.major_class:
+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'})
+137 -107
View File
@@ -21,6 +21,7 @@ from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -44,11 +45,12 @@ dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None
# Audio mux: the sole reader of dsd-fme stdout. Writes to an ffmpeg
# stdin when a streaming client is connected, discards otherwise.
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
# active ffmpeg stdin sinks when streaming clients are connected.
# This prevents dsd-fme from blocking on stdout (which would also
# freeze stderr / text data output).
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint
_ffmpeg_sinks: set[object] = set()
_ffmpeg_sinks_lock = threading.Lock()
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
@@ -68,9 +70,9 @@ _DSD_PROTOCOL_FLAGS = {
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
# -ft = XDMA multi-protocol decoder
_DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!)
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
'nxdn': ['-fn'], # NXDN96
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
@@ -80,7 +82,6 @@ _DSD_FME_PROTOCOL_FLAGS = {
# sync reliability vs letting dsd-fme auto-detect modulation type.
_DSD_FME_MODULATION = {
'dmr': ['-mc'], # C4FM
'p25': ['-mc'], # C4FM (Phase 1; Phase 2 would use -mq)
'nxdn': ['-mc'], # C4FM
}
@@ -109,6 +110,11 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
@@ -214,14 +220,66 @@ _HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
_SILENCE_CHUNK = b'\x00' * 1600
def _register_audio_sink(sink: object) -> None:
"""Register an ffmpeg stdin sink for mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.add(sink)
def _unregister_audio_sink(sink: object) -> None:
"""Remove an ffmpeg stdin sink from mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.discard(sink)
def _get_audio_sinks() -> tuple[object, ...]:
"""Snapshot current audio sinks for lock-free iteration."""
with _ffmpeg_sinks_lock:
return tuple(_ffmpeg_sinks)
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
"""Terminate and unregister a subprocess if present."""
if not proc:
return
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
def _reset_runtime_state(*, release_device: bool) -> None:
"""Reset process + runtime state and optionally release SDR ownership."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
_stop_process(dmr_dsd_process)
_stop_process(dmr_rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
if release_device and dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
def _dsd_audio_mux(dsd_stdout):
"""Mux thread: sole reader of dsd-fme stdout.
Always drains dsd-fme's audio output to prevent the process from
blocking on stdout writes (which would also freeze stderr / text
data). When an audio streaming client is connected, forwards audio
to its ffmpeg stdin with silence fill during voice gaps. When no
client is connected, simply discards the data.
data). When streaming clients are connected, forwards data to all
active ffmpeg stdin sinks with silence fill during voice gaps.
"""
try:
while dmr_running:
@@ -230,22 +288,22 @@ def _dsd_audio_mux(dsd_stdout):
data = os.read(dsd_stdout.fileno(), 4096)
if not data:
break
sink = _active_ffmpeg_stdin
if sink:
sinks = _get_audio_sinks()
for sink in sinks:
try:
sink.write(data)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
pass
_unregister_audio_sink(sink)
else:
# No audio from decoder — feed silence if client connected
sink = _active_ffmpeg_stdin
if sink:
sinks = _get_audio_sinks()
for sink in sinks:
try:
sink.write(_SILENCE_CHUNK)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
pass
_unregister_audio_sink(sink)
except (OSError, ValueError):
pass
@@ -316,7 +374,11 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
logger.error(f"DSD stream error: {e}")
finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
global dmr_has_audio
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
# Capture exit info for diagnostics
rc = dsd_process.poll()
reason = 'stopped'
@@ -331,18 +393,8 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup decoder + demod processes
for proc in [dsd_process, rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
_stop_process(dsd_process)
_stop_process(rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
@@ -362,12 +414,14 @@ def check_tools() -> Response:
"""Check for required tools."""
dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
return jsonify({
'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None,
'available': dsd_path is not None and rtl_fm is not None,
'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
'protocols': VALID_PROTOCOLS,
})
@@ -378,18 +432,10 @@ def start_dmr() -> Response:
global dmr_rtl_process, dmr_dsd_process, dmr_thread
global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dsd_path, is_fme = find_dsd()
if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
data = request.json or {}
try:
@@ -401,9 +447,25 @@ def start_dmr() -> Response:
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
if sdr_type == SDRType.RTL_SDR:
if not find_rtl_fm():
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
else:
if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 503
# Clear stale queue
try:
while True:
@@ -411,32 +473,45 @@ def start_dmr() -> Response:
except queue.Empty:
pass
# Reserve running state before we start claiming resources/processes
# so concurrent /start requests cannot race each other.
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dmr_running = True
dmr_has_audio = False
# Claim SDR device — use protocol name so the device panel shows
# "D-STAR", "P25", etc. instead of always "DMR"
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
error = app_module.claim_sdr_device(device, mode_label)
if error:
with dmr_lock:
dmr_running = False
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device
freq_hz = int(frequency * 1e6)
# Build rtl_fm command (48kHz sample rate for DSD).
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
# mid-frame, destroying DSD sync. The decoder handles silence
# internally via its own frame-sync detection.
rtl_cmd = [
rtl_fm_path,
'-M', 'fm',
'-f', str(freq_hz),
'-s', '48000',
'-g', str(gain),
'-d', str(device),
'-l', '0',
]
if ppm != 0:
rtl_cmd.extend(['-p', str(ppm)])
# Build FM demodulation command via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=frequency,
sample_rate=48000,
gain=float(gain) if gain > 0 else None,
ppm=int(ppm) if ppm != 0 else None,
modulation='fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR:
# Keep squelch fully open for digital bitstreams.
rtl_cmd.extend(['-l', '0'])
except Exception as e:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build DSD command
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
@@ -508,25 +583,8 @@ def start_dmr() -> Response:
if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
# Terminate surviving processes and unregister all
dmr_has_audio = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
# Terminate surviving processes and release resources.
_reset_runtime_state(release_device=True)
# Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip()
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
@@ -547,7 +605,6 @@ def start_dmr() -> Response:
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
dmr_running = True
dmr_thread = threading.Thread(
target=stream_dsd_output,
args=(dmr_rtl_process, dmr_dsd_process),
@@ -559,46 +616,21 @@ def start_dmr() -> Response:
'status': 'started',
'frequency': frequency,
'protocol': protocol,
'sdr_type': sdr_type.value,
'has_audio': dmr_has_audio,
})
except Exception as e:
logger.error(f"Failed to start DMR: {e}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response:
"""Stop digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock:
dmr_running = False
dmr_has_audio = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
_reset_runtime_state(release_device=True)
return jsonify({'status': 'stopped'})
@@ -625,8 +657,6 @@ def stream_dmr_audio() -> Response:
it, back-pressuring the entire pipeline and freezing stderr/text
data output).
"""
global _active_ffmpeg_stdin
if not dmr_running or not dmr_has_audio:
return Response(b'', mimetype='audio/wav', status=204)
@@ -653,11 +683,10 @@ def stream_dmr_audio() -> Response:
args=(audio_proc,), daemon=True,
).start()
# Tell the mux thread to start writing to this ffmpeg
_active_ffmpeg_stdin = audio_proc.stdin
if audio_proc.stdin:
_register_audio_sink(audio_proc.stdin)
def generate():
global _active_ffmpeg_stdin
try:
while dmr_running and audio_proc.poll() is None:
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
@@ -676,7 +705,8 @@ def stream_dmr_audio() -> Response:
logger.error(f"DMR audio stream error: {e}")
finally:
# Disconnect mux → ffmpeg, then clean up
_active_ffmpeg_stdin = None
if audio_proc.stdin:
_unregister_audio_sink(audio_proc.stdin)
try:
audio_proc.stdin.close()
except Exception:
+60 -14
View File
@@ -4,19 +4,20 @@ from __future__ import annotations
import queue
import time
from typing import Generator
from collections.abc import Generator
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify
from utils.logging import get_logger
from utils.sse import format_sse
from utils.gps import (
GPSPosition,
GPSSkyData,
get_current_position,
get_gps_reader,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition,
)
from utils.logging import get_logger
from utils.sse import format_sse
logger = get_logger('intercept.gps')
@@ -29,12 +30,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
def _position_callback(position: GPSPosition) -> None:
"""Callback to queue position updates for SSE stream."""
try:
_gps_queue.put_nowait(position.to_dict())
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Full:
# Discard oldest if queue is full
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait(position.to_dict())
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Empty:
pass
def _sky_callback(sky: GPSSkyData) -> None:
"""Callback to queue sky data updates for SSE stream."""
try:
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Full:
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Empty:
pass
@@ -53,11 +66,13 @@ def auto_connect_gps():
reader = get_gps_reader()
if reader and reader.is_running:
position = reader.position
sky = reader.sky
return jsonify({
'status': 'connected',
'source': 'gpsd',
'has_fix': position is not None,
'position': position.to_dict() if position else None
'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
})
# Try to connect to gpsd on localhost:2947
@@ -84,14 +99,17 @@ def auto_connect_gps():
break
# Start the gpsd client
success = start_gpsd(host, port, callback=_position_callback)
success = start_gpsd(host, port,
callback=_position_callback,
sky_callback=_sky_callback)
if success:
return jsonify({
'status': 'connected',
'source': 'gpsd',
'has_fix': False,
'position': None
'position': None,
'sky': None,
})
else:
return jsonify({
@@ -106,6 +124,7 @@ def stop_gps_reader():
reader = get_gps_reader()
if reader:
reader.remove_callback(_position_callback)
reader.remove_sky_callback(_sky_callback)
stop_gps()
@@ -122,15 +141,18 @@ def get_gps_status():
'running': False,
'device': None,
'position': None,
'sky': None,
'error': None,
'message': 'GPS client not started'
})
position = reader.position
sky = reader.sky
return jsonify({
'running': reader.is_running,
'device': reader.device_path,
'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,
'error': reader.error,
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
@@ -161,18 +183,42 @@ def get_position():
})
@gps_bp.route('/satellites')
def get_satellites():
"""Get current satellite sky view data."""
reader = get_gps_reader()
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS client not running'
}), 400
sky = reader.sky
if sky:
return jsonify({
'status': 'ok',
'sky': sky.to_dict()
})
else:
return jsonify({
'status': 'waiting',
'message': 'Waiting for satellite data'
})
@gps_bp.route('/stream')
def stream_gps():
"""SSE stream of GPS position updates."""
"""SSE stream of GPS position and sky updates."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
position = _gps_queue.get(timeout=1)
data = _gps_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse({'type': 'position', **position})
yield format_sse(data)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
+50 -27
View File
@@ -102,15 +102,21 @@ def find_ffmpeg() -> str | None:
return shutil.which('ffmpeg')
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
@@ -207,14 +213,14 @@ def scanner_loop():
resample_rate = 24000
# Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [
rtl_fm_path,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
rtl_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
]
# Add bias-t flag if enabled (for external LNA power)
if scanner_config.get('bias_t', False):
@@ -679,14 +685,14 @@ def _start_audio_stream(frequency: float, modulation: str):
return
freq_hz = int(frequency * 1e6)
sdr_cmd = [
rtl_fm_path,
'-M', modulation,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
sdr_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(modulation),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']),
]
if scanner_config.get('bias_t', False):
@@ -1456,13 +1462,30 @@ def stream_audio() -> Response:
if not proc or not proc.stdout:
return
try:
# First byte timeout to avoid hanging clients forever
# Drain stale audio that accumulated in the pipe buffer
# between pipeline start and stream connection. Keep the
# first chunk (contains WAV header) and discard the rest
# so the browser starts close to real-time.
header_chunk = None
while True:
ready, _, _ = select.select([proc.stdout], [], [], 0)
if not ready:
break
chunk = proc.stdout.read(8192)
if not chunk:
break
if header_chunk is None:
header_chunk = chunk
if header_chunk:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 3.0
while audio_running and proc.poll() is None:
# Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = proc.stdout.read(4096)
chunk = proc.stdout.read(8192)
if chunk:
yield chunk
else:
+102 -15
View File
@@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
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.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
@@ -31,18 +38,38 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_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():
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading
def _auto_refresh_tle():
try:
_load_db_satellites_into_cache()
updated = refresh_tle_data()
if updated:
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
except Exception as e:
logger.warning(f"Auto TLE refresh failed: {e}")
# Start auto-refresh in background
threading.Timer(2.0, _auto_refresh_tle).start()
logger.info("TLE auto-refresh scheduled")
@@ -170,15 +197,11 @@ def predict_passes():
norad_to_name = {
25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
@@ -189,10 +212,6 @@ def predict_passes():
passes = []
colors = {
'ISS': '#00ffff',
'NOAA-15': '#00ff00',
'NOAA-18': '#ff6600',
'NOAA-19': '#ff3366',
'NOAA-20': '#00ffaa',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff'
}
@@ -329,10 +348,6 @@ def get_satellite_position():
norad_to_name = {
25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
@@ -555,3 +570,75 @@ def fetch_celestrak(category):
except Exception as e:
logger.error(f"Error fetching CelesTrak data: {e}")
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
+424
View File
@@ -0,0 +1,424 @@
"""SubGHz transceiver routes.
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
signal replay/transmit, and wideband spectrum analysis.
"""
from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import sse_stream
from utils.subghz import get_subghz_manager
from utils.constants import (
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ,
SUBGHZ_LNA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MAX,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_TX_MAX_DURATION,
SUBGHZ_SAMPLE_RATES,
SUBGHZ_PRESETS,
)
logger = get_logger('intercept.subghz')
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
# SSE queue for streaming events to frontend
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue."""
try:
_subghz_queue.put_nowait(event)
except queue.Full:
try:
_subghz_queue.get_nowait()
_subghz_queue.put_nowait(event)
except queue.Empty:
pass
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
raw = data.get(key)
if raw is None:
return None, f'{key} is required'
try:
freq_hz = int(raw)
freq_mhz = freq_hz / 1_000_000
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
return freq_hz, None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_serial(data: dict) -> str | None:
"""Extract and validate optional HackRF device serial."""
serial = data.get('device_serial', '')
if not serial or not isinstance(serial, str):
return None
# HackRF serials are hex strings
serial = serial.strip()
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
return serial
return None
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping."""
try:
val = int(data.get(key, default))
return max(min_val, min(max_val, val))
except (ValueError, TypeError):
return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default)
if not isinstance(profile, str):
return default
profile = profile.strip().lower()
if profile in {'weather', 'all'}:
return profile
return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key)
if raw is None or raw == '':
return None, None
try:
return float(raw), None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default
# ------------------------------------------------------------------
# STATUS
# ------------------------------------------------------------------
@subghz_bp.route('/status')
def get_status():
manager = get_subghz_manager()
return jsonify(manager.get_status())
@subghz_bp.route('/presets')
def get_presets():
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
# ------------------------------------------------------------------
# RECEIVE
# ------------------------------------------------------------------
@subghz_bp.route('/receive/start', methods=['POST'])
def start_receive():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_receive(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/receive/stop', methods=['POST'])
def stop_receive():
manager = get_subghz_manager()
result = manager.stop_receive()
return jsonify(result)
# ------------------------------------------------------------------
# DECODE
# ------------------------------------------------------------------
@subghz_bp.route('/decode/start', methods=['POST'])
def start_decode():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_decode(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
decode_profile=decode_profile,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/decode/stop', methods=['POST'])
def stop_decode():
manager = get_subghz_manager()
result = manager.stop_decode()
return jsonify(result)
# ------------------------------------------------------------------
# TRANSMIT
# ------------------------------------------------------------------
@subghz_bp.route('/transmit', methods=['POST'])
def start_transmit():
data = request.get_json(silent=True) or {}
capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
# Sanitize capture_id
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.transmit(
capture_id=capture_id,
tx_gain=tx_gain,
max_duration=max_duration,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 400
return jsonify(result), status_code
@subghz_bp.route('/transmit/stop', methods=['POST'])
def stop_transmit():
manager = get_subghz_manager()
result = manager.stop_transmit()
return jsonify(result)
# ------------------------------------------------------------------
# SWEEP
# ------------------------------------------------------------------
@subghz_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
data = request.get_json(silent=True) or {}
try:
freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_sweep(
freq_start_mhz=freq_start,
freq_end_mhz=freq_end,
bin_width=bin_width,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
manager = get_subghz_manager()
result = manager.stop_sweep()
return jsonify(result)
# ------------------------------------------------------------------
# CAPTURES LIBRARY
# ------------------------------------------------------------------
@subghz_bp.route('/captures')
def list_captures():
manager = get_subghz_manager()
captures = manager.list_captures()
return jsonify({
'status': 'ok',
'captures': [c.to_dict() for c in captures],
'count': len(captures),
})
@subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
capture = manager.get_capture(capture_id)
if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
path = manager.get_capture_path(capture_id)
if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return send_file(
path,
mimetype='application/octet-stream',
as_attachment=True,
download_name=path.name,
)
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
label = data.get('label', '')
if label is None:
label = ''
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
result = manager.trim_capture(
capture_id=capture_id,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
label=label,
)
if result.get('status') == 'ok':
return jsonify(result), 200
message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
# ------------------------------------------------------------------
# SSE STREAM
# ------------------------------------------------------------------
@subghz_bp.route('/stream')
def stream():
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+86 -7
View File
@@ -214,6 +214,8 @@ check_tools() {
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_optional "hackrf_transfer" "HackRF SubGHz transceiver" hackrf_transfer
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
@@ -569,10 +571,43 @@ install_acarsdec_from_source_macos() {
|| { warn "Failed to clone acarsdec"; exit 1; }
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
# 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..."
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
install -m 0755 acarsdec /usr/local/bin/acarsdec
else
@@ -581,6 +616,8 @@ install_acarsdec_from_source_macos() {
ok "acarsdec installed successfully from source"
else
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
)
}
@@ -638,9 +675,24 @@ install_satdump_from_source_debian() {
cd "$tmp_dir/SatDump"
mkdir -p build && cd build
info "Compiling SatDump (this may take a while)..."
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >/dev/null 2>&1 \
&& make -j "$(nproc)" >/dev/null 2>&1; then
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
build_log="$tmp_dir/satdump-build.log"
# 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 ldconfig
@@ -657,7 +709,10 @@ install_satdump_from_source_debian() {
ok "SatDump installed successfully."
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 "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi
)
}
@@ -688,9 +743,24 @@ install_satdump_from_source_macos() {
cd "$tmp_dir/SatDump"
mkdir -p build && cd build
info "Compiling SatDump (this may take a while)..."
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >/dev/null 2>&1 \
&& make -j "$(sysctl -n hw.ncpu)" >/dev/null 2>&1; then
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
build_log="$tmp_dir/satdump-build.log"
# 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
make install >/dev/null 2>&1
else
@@ -698,7 +768,10 @@ install_satdump_from_source_macos() {
fi
ok "SatDump installed successfully."
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 "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi
)
}
@@ -746,6 +819,9 @@ install_macos_packages() {
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing HackRF tools"
brew_install hackrf
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
@@ -1169,6 +1245,9 @@ install_debian_packages() {
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing HackRF tools"
apt_install hackrf || warn "hackrf tools not available"
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
+121 -3
View File
@@ -1448,6 +1448,7 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
overflow: hidden;
position: relative;
}
@media (min-width: 1024px) {
@@ -1457,6 +1458,18 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
}
.main-content.sidebar-collapsed {
grid-template-columns: 0 1fr;
}
.main-content.sidebar-collapsed > .sidebar {
width: 0;
min-width: 0;
padding: 0;
border-right: 0;
overflow: hidden;
}
}
.sidebar {
@@ -1480,6 +1493,63 @@ header h1 .tagline {
display: none;
}
.sidebar-collapse-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sidebar-collapse-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.sidebar-expand-handle {
display: none;
position: absolute;
top: 12px;
left: 10px;
z-index: 12;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--accent-cyan);
border-radius: 6px;
cursor: pointer;
}
.main-content.sidebar-collapsed .sidebar-expand-handle {
display: inline-flex;
}
/* Reserve space for the expand handle so it doesn't overlap mode titles */
.main-content.sidebar-collapsed .output-header {
padding-left: 48px;
}
@media (max-width: 1023px) {
.sidebar-collapse-btn,
.sidebar-expand-handle {
display: none !important;
}
}
.section {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -1528,8 +1598,10 @@ header h1 .tagline {
.section.collapsed h3 {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 10px;
margin-bottom: 0 !important;
min-height: 0 !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
}
.section.collapsed h3::after {
@@ -1538,7 +1610,8 @@ header h1 .tagline {
}
.section.collapsed {
padding-bottom: 0;
padding-bottom: 0 !important;
min-height: 0;
}
.section.collapsed>*:not(h3) {
@@ -2313,6 +2386,45 @@ header h1 .tagline {
display: block;
}
/* Normalize spacing for all sidebar mode panels */
.sidebar .mode-content.active:not(#meshtasticMode) {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > * {
margin: 0 !important;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .section {
margin: 0 !important;
}
.mode-actions-bottom {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .mode-actions-bottom {
margin-top: auto !important;
}
#btMessageContainer:empty {
display: none;
}
.alpha-mode-notice {
padding: 8px 10px;
border: 1px solid rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
color: var(--accent-yellow);
border-radius: 6px;
font-size: 10px;
line-height: 1.45;
}
/* Aircraft (ADS-B) Styles */
.aircraft-card {
padding: 12px;
@@ -4472,6 +4584,12 @@ header h1 .tagline {
text-overflow: ellipsis;
}
.bt-row-actions {
display: flex;
justify-content: flex-end;
padding: 4px 4px 0 42px;
}
/* Bluetooth Device Modal */
.bt-modal-overlay {
position: fixed;
+47
View File
@@ -326,3 +326,50 @@
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
/* APRS map markers (flat SVG icons) */
.aprs-map-marker-wrap {
background: transparent;
border: none;
}
.aprs-map-marker {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px 2px 5px;
border-radius: 999px;
border: 1px solid rgba(74, 158, 255, 0.35);
background: rgba(10, 18, 28, 0.88);
color: var(--text-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.aprs-map-marker-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--accent-cyan);
}
.aprs-map-marker-icon svg {
width: 14px;
height: 14px;
display: block;
fill: currentColor;
}
.aprs-map-marker-label {
font-size: 9px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
.aprs-map-marker.vehicle .aprs-map-marker-icon {
color: var(--accent-green);
}
.aprs-map-marker.tower .aprs-map-marker-icon {
color: var(--accent-cyan);
}
+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;
}
}
+332
View File
@@ -0,0 +1,332 @@
/* GPS Mode Styles */
/* Sidebar info grid */
.gps-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.gps-info-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
}
.gps-info-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-info-value {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
.gps-mono {
font-family: var(--font-mono);
}
/* Connection status */
.gps-connection-status {
display: flex;
align-items: center;
gap: 6px;
}
.gps-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
}
.gps-status-dot.connected {
background: #00ff88;
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
}
.gps-status-dot.waiting {
background: #ffaa00;
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
}
.gps-status-text {
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
/* Fix badge */
.gps-fix-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
}
.gps-fix-badge.no-fix {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
border: 1px solid rgba(255, 68, 68, 0.3);
}
.gps-fix-badge.fix-2d {
background: rgba(255, 170, 0, 0.2);
color: #ffaa00;
border: 1px solid rgba(255, 170, 0, 0.3);
}
.gps-fix-badge.fix-3d {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
border: 1px solid rgba(0, 255, 136, 0.3);
}
/* DOP quality indicators */
.gps-dop-good { color: #00ff88; }
.gps-dop-moderate { color: #ffaa00; }
.gps-dop-poor { color: #ff4444; }
/* ===== Visuals Panel ===== */
.gps-visuals-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow-y: auto;
}
/* Top row: sky view + position info */
.gps-visuals-top {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
/* Sky View */
.gps-skyview-panel {
flex: 1;
min-width: 320px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.gps-skyview-panel h4 {
margin: 0 0 8px 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-skyview-canvas-wrap {
display: flex;
justify-content: center;
align-items: center;
}
#gpsSkyCanvas {
max-width: 100%;
height: auto;
}
/* Position info panel */
.gps-position-panel {
flex: 1;
min-width: 280px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.gps-position-panel h4 {
margin: 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-pos-big {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
color: var(--accent-cyan);
line-height: 1.3;
}
.gps-pos-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
}
.gps-pos-row:last-child {
border-bottom: none;
}
.gps-pos-label {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
}
.gps-pos-value {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
}
/* Signal Strength Bars */
.gps-signal-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.gps-signal-panel h4 {
margin: 0 0 8px 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-signal-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 140px;
padding: 0 4px;
overflow-x: auto;
}
.gps-signal-bar-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 18px;
height: 100%;
justify-content: flex-end;
}
.gps-signal-bar {
width: 14px;
border-radius: 2px 2px 0 0;
min-height: 2px;
transition: height 0.3s ease;
}
.gps-signal-bar.unused {
opacity: 0.4;
}
.gps-signal-prn {
font-size: 8px;
font-family: var(--font-mono);
color: var(--text-dim);
writing-mode: horizontal-tb;
}
.gps-signal-snr {
font-size: 7px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
/* Constellation colors */
.gps-const-gps { background-color: #00d4ff; }
.gps-const-glonass { background-color: #00ff88; }
.gps-const-galileo { background-color: #ff8800; }
.gps-const-beidou { background-color: #ff4466; }
.gps-const-sbas { background-color: #ffdd00; }
.gps-const-qzss { background-color: #cc66ff; }
/* Legend */
.gps-legend {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.gps-legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: var(--text-dim);
}
.gps-legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Empty state */
.gps-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
color: var(--text-dim);
text-align: center;
}
.gps-empty-state svg {
width: 48px;
height: 48px;
opacity: 0.3;
}
.gps-empty-state p {
font-size: 12px;
max-width: 300px;
line-height: 1.5;
}
/* Responsive */
@media (max-width: 768px) {
.gps-visuals-top {
flex-direction: column;
}
.gps-skyview-panel,
.gps-position-panel {
min-width: unset;
}
.gps-pos-big {
font-size: 16px;
}
}
+3 -1
View File
@@ -340,7 +340,9 @@
MODE VISIBILITY - Ensure sidebar shows when active
============================================ */
#spystationsMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvGeneralMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -114,7 +114,7 @@
position: fixed;
top: 0;
left: 0;
width: min(320px, 85vw);
width: min(360px, 100vw);
height: 100dvh;
height: 100vh; /* Fallback */
background: var(--bg-secondary, #0f1218);
@@ -381,6 +381,33 @@
-webkit-overflow-scrolling: touch;
}
.sidebar {
padding: 10px;
gap: 10px;
}
.output-panel {
min-height: 58vh;
}
.output-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-controls {
width: 100%;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.header-controls .stats {
min-width: max-content;
}
/* Container should not clip content */
.container {
overflow: visible;
+28 -4
View File
@@ -24,7 +24,7 @@
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
max-width: 900px;
width: 100%;
max-height: calc(100vh - 80px);
display: flex;
@@ -74,22 +74,28 @@
/* Settings Tabs */
.settings-tabs {
display: flex;
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
gap: 0;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
padding: 12px 10px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-tab:hover {
@@ -474,6 +480,12 @@
}
/* Responsive */
@media (max-width: 960px) {
.settings-tabs {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
@@ -483,6 +495,18 @@
max-width: 100%;
}
.settings-tabs {
padding: 0 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-tab {
padding: 10px 6px;
font-size: 11px;
white-space: normal;
line-height: 1.2;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
+5 -3
View File
@@ -488,10 +488,12 @@ function initApp() {
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
if (index > 0) {
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.sidebar .section').forEach((section) => {
if (section.querySelector('h3')) {
section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
}
});
+118 -75
View File
@@ -366,10 +366,10 @@ const BluetoothMode = (function() {
// Badges
const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
// Tracker badge
if (device.is_tracker) {
@@ -451,14 +451,14 @@ const BluetoothMode = (function() {
? minMax[0] + '/' + minMax[1]
: '--';
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
updateWatchlistButton(device);
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
updateWatchlistButton(device);
// Services
const servicesContainer = document.getElementById('btDetailServices');
@@ -470,29 +470,29 @@ const BluetoothMode = (function() {
servicesContainer.style.display = 'none';
}
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Highlight selected device in list
highlightSelectedDevice(deviceId);
}
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
}
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
/**
* Clear device selection
@@ -546,43 +546,43 @@ const BluetoothMode = (function() {
/**
* Copy selected device address to clipboard
*/
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.getElementById('btDetailCopyBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.getElementById('btDetailCopyBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
}
});
}
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
});
}
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
/**
* Select a device - opens modal with details
@@ -1130,11 +1130,11 @@ const BluetoothMode = (function() {
const isNew = !inBaseline;
const hasName = !!device.name;
const isTracker = device.is_tracker === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
// Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong)
@@ -1186,9 +1186,9 @@ const BluetoothMode = (function() {
// Build secondary info line
let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
@@ -1216,6 +1216,11 @@ const BluetoothMode = (function() {
'</div>' +
'</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>';
}
@@ -1391,6 +1396,42 @@ const BluetoothMode = (function() {
updateRadar();
}
/**
* Hand off a device to BT Locate mode by device_id lookup.
*/
function locateById(deviceId) {
console.log('[BT] locateById called with:', deviceId);
const device = devices.get(deviceId);
if (!device) {
console.warn('[BT] Device not found in map for id:', deviceId);
return;
}
doLocateHandoff(device);
}
/**
* Hand off the currently selected device to BT Locate mode.
*/
function locateDevice() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
doLocateHandoff(device);
}
function doLocateHandoff(device) {
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
if (typeof BtLocate !== 'undefined') {
BtLocate.handoff({
device_id: device.device_id,
mac_address: device.address,
known_name: device.name || null,
known_manufacturer: device.manufacturer_name || null,
last_known_rssi: device.rssi_current
});
}
}
// Public API
return {
init,
@@ -1400,10 +1441,12 @@ const BluetoothMode = (function() {
setBaseline,
clearBaseline,
exportData,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
locateDevice,
locateById,
// Agent handling
handleAgentChange,
+732
View File
@@ -0,0 +1,732 @@
/**
* BT Locate Bluetooth SAR Device Location Mode
* GPS-tagged signal trail mapping with proximity audio alerts.
*/
const BtLocate = (function() {
'use strict';
let eventSource = null;
let map = null;
let mapMarkers = [];
let trailLine = null;
let rssiHistory = [];
const MAX_RSSI_POINTS = 60;
let chartCanvas = null;
let chartCtx = null;
let currentEnvironment = 'OUTDOOR';
let audioCtx = null;
let audioEnabled = false;
let beepTimer = null;
let initialized = false;
let handoffData = null;
let pollTimer = null;
let durationTimer = null;
let sessionStartedAt = null;
let lastDetectionCount = 0;
function init() {
if (initialized) {
// Re-invalidate map on re-entry and ensure tiles are present
if (map) {
setTimeout(() => {
map.invalidateSize();
// Re-apply user's tile layer if tiles were lost
let hasTiles = false;
map.eachLayer(layer => {
if (layer instanceof L.TileLayer) hasTiles = true;
});
if (!hasTiles && typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(map);
}
}, 150);
}
checkStatus();
return;
}
// Init map
const mapEl = document.getElementById('btLocateMap');
if (mapEl && typeof L !== 'undefined') {
map = L.map('btLocateMap', {
center: [0, 0],
zoom: 2,
zoomControl: true,
});
// Use tile provider from user settings
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(map);
Settings.registerMap(map);
} else {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '&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 || '';
targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--';
}
// Environment info
const envEl = document.getElementById('btLocateEnvInfo');
if (envEl) {
const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' };
envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?');
}
// GPS status
const gpsEl = document.getElementById('btLocateGpsStatus');
if (gpsEl) {
const src = data.gps_source || 'none';
if (src === 'live') gpsEl.textContent = 'GPS: Live';
else if (src === 'manual') gpsEl.textContent = 'GPS: Manual';
else gpsEl.textContent = 'GPS: None';
}
// Last seen
const lastEl = document.getElementById('btLocateLastSeen');
if (lastEl) {
if (data.last_detection) {
const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000);
lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago');
} else {
lastEl.textContent = 'Last: --';
}
}
// Session start time (duration handled by 1s timer)
if (data.started_at && !sessionStartedAt) {
sessionStartedAt = new Date(data.started_at).getTime();
}
}
function handleDetection(event) {
const d = event.data;
if (!d) return;
// Update proximity UI
const bandEl = document.getElementById('btLocateBand');
const distEl = document.getElementById('btLocateDistance');
const rssiEl = document.getElementById('btLocateRssi');
const rssiEmaEl = document.getElementById('btLocateRssiEma');
if (bandEl) {
bandEl.textContent = d.proximity_band;
bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase();
}
if (distEl) distEl.textContent = d.estimated_distance.toFixed(1);
if (rssiEl) rssiEl.textContent = d.rssi;
if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
// RSSI sparkline
rssiHistory.push(d.rssi);
if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
drawRssiChart();
// Map marker
if (d.lat != null && d.lon != null) {
addMapMarker(d);
}
// Update stats
const detCountEl = document.getElementById('btLocateDetectionCount');
const gpsCountEl = document.getElementById('btLocateGpsCount');
if (detCountEl) {
const cur = parseInt(detCountEl.textContent) || 0;
detCountEl.textContent = cur + 1;
}
if (gpsCountEl && d.lat != null) {
const cur = parseInt(gpsCountEl.textContent) || 0;
gpsCountEl.textContent = cur + 1;
}
// Audio
if (audioEnabled) playProximityTone(d.rssi);
}
function updateStats(detections, gpsPoints) {
const detCountEl = document.getElementById('btLocateDetectionCount');
const gpsCountEl = document.getElementById('btLocateGpsCount');
if (detCountEl) detCountEl.textContent = detections || 0;
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
}
function addMapMarker(point) {
if (!map || point.lat == null || point.lon == null) return;
const band = (point.proximity_band || 'FAR').toLowerCase();
const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
const sizes = { immediate: 8, near: 6, far: 5 };
const color = colors[band] || '#eab308';
const radius = sizes[band] || 5;
const marker = L.circleMarker([point.lat, point.lon], {
radius: radius,
fillColor: color,
color: '#fff',
weight: 1,
opacity: 0.9,
fillOpacity: 0.8,
}).addTo(map);
marker.bindPopup(
'<div style="font-family:monospace;font-size:11px;">' +
'<b>' + point.proximity_band + '</b><br>' +
'RSSI: ' + point.rssi + ' dBm<br>' +
'Distance: ~' + point.estimated_distance.toFixed(1) + ' m<br>' +
'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
'</div>'
);
mapMarkers.push(marker);
map.panTo([point.lat, point.lon]);
// Update trail line
const latlngs = mapMarkers.map(m => m.getLatLng());
if (trailLine) {
trailLine.setLatLngs(latlngs);
} else if (latlngs.length >= 2) {
trailLine = L.polyline(latlngs, {
color: 'rgba(0,255,136,0.5)',
weight: 2,
dashArray: '4 4',
}).addTo(map);
}
}
function restoreTrail() {
fetch('/bt_locate/trail')
.then(r => r.json())
.then(trail => {
if (trail.gps_trail && trail.gps_trail.length > 0) {
clearMapMarkers();
trail.gps_trail.forEach(p => addMapMarker(p));
}
if (trail.trail && trail.trail.length > 0) {
// Restore RSSI history from trail
rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
drawRssiChart();
// Update HUD with latest detection
const latest = trail.trail[trail.trail.length - 1];
handleDetection({ data: latest });
}
})
.catch(() => {});
}
function clearMapMarkers() {
mapMarkers.forEach(m => map?.removeLayer(m));
mapMarkers = [];
if (trailLine) {
map?.removeLayer(trailLine);
trailLine = null;
}
}
function drawRssiChart() {
if (!chartCtx || !chartCanvas) return;
const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16;
const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24;
chartCtx.clearRect(0, 0, w, h);
if (rssiHistory.length < 2) return;
// RSSI range: -100 to -20
const minR = -100, maxR = -20;
const range = maxR - minR;
// Grid lines
chartCtx.strokeStyle = 'rgba(255,255,255,0.05)';
chartCtx.lineWidth = 1;
[-30, -50, -70, -90].forEach(v => {
const y = h - ((v - minR) / range) * h;
chartCtx.beginPath();
chartCtx.moveTo(0, y);
chartCtx.lineTo(w, y);
chartCtx.stroke();
});
// Draw RSSI line
const step = w / (MAX_RSSI_POINTS - 1);
chartCtx.beginPath();
chartCtx.strokeStyle = '#00ff88';
chartCtx.lineWidth = 2;
rssiHistory.forEach((rssi, i) => {
const x = i * step;
const y = h - ((rssi - minR) / range) * h;
if (i === 0) chartCtx.moveTo(x, y);
else chartCtx.lineTo(x, y);
});
chartCtx.stroke();
// Fill under
const lastIdx = rssiHistory.length - 1;
chartCtx.lineTo(lastIdx * step, h);
chartCtx.lineTo(0, h);
chartCtx.closePath();
chartCtx.fillStyle = 'rgba(0,255,136,0.08)';
chartCtx.fill();
}
// Audio proximity tone (Web Audio API)
function playTone(freq, duration) {
if (!audioCtx || audioCtx.state !== 'running') return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.frequency.value = freq;
osc.type = 'sine';
gain.gain.value = 0.2;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playProximityTone(rssi) {
if (!audioCtx || audioCtx.state !== 'running') return;
// Stronger signal = higher pitch and shorter beep
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
const freq = 400 + strength * 800; // 400-1200 Hz
const duration = 0.06 + (1 - strength) * 0.12;
playTone(freq, duration);
}
function toggleAudio() {
const cb = document.getElementById('btLocateAudioEnable');
audioEnabled = cb?.checked || false;
if (audioEnabled) {
// Create AudioContext on user gesture (required by browser policy)
if (!audioCtx) {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.error('[BtLocate] AudioContext creation failed:', e);
return;
}
}
// Resume must happen within a user gesture handler
const ctx = audioCtx;
ctx.resume().then(() => {
console.log('[BtLocate] AudioContext state:', ctx.state);
// Confirmation beep so user knows audio is working
playTone(600, 0.08);
});
} else {
stopAudio();
}
}
function stopAudio() {
audioEnabled = false;
const cb = document.getElementById('btLocateAudioEnable');
if (cb) cb.checked = false;
}
function setEnvironment(env) {
currentEnvironment = env;
document.querySelectorAll('.btl-env-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.env === env);
});
// Push to running session if active
fetch('/bt_locate/status').then(r => r.json()).then(data => {
if (data.active) {
fetch('/bt_locate/environment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment: env }),
}).then(r => r.json()).then(res => {
console.log('[BtLocate] Environment updated:', res);
});
}
}).catch(() => {});
}
function handoff(deviceInfo) {
console.log('[BtLocate] Handoff received:', deviceInfo);
handoffData = deviceInfo;
// Populate fields
if (deviceInfo.mac_address) {
const macInput = document.getElementById('btLocateMac');
if (macInput) macInput.value = deviceInfo.mac_address;
}
// Show handoff card
const card = document.getElementById('btLocateHandoffCard');
const nameEl = document.getElementById('btLocateHandoffName');
const metaEl = document.getElementById('btLocateHandoffMeta');
if (card) card.style.display = '';
if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown';
if (metaEl) {
const parts = [];
if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address);
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
metaEl.textContent = parts.join(' \u00b7 ');
}
// Switch to bt_locate mode
if (typeof switchMode === 'function') {
switchMode('bt_locate');
}
}
function clearHandoff() {
handoffData = null;
const card = document.getElementById('btLocateHandoffCard');
if (card) card.style.display = 'none';
}
function fetchPairedIrks() {
const picker = document.getElementById('btLocateIrkPicker');
const status = document.getElementById('btLocateIrkPickerStatus');
const list = document.getElementById('btLocateIrkPickerList');
const btn = document.getElementById('btLocateDetectIrkBtn');
if (!picker || !status || !list) return;
// Toggle off if already visible
if (picker.style.display !== 'none') {
picker.style.display = 'none';
return;
}
picker.style.display = '';
list.innerHTML = '';
status.textContent = 'Scanning paired devices...';
status.style.display = '';
if (btn) btn.disabled = true;
fetch('/bt_locate/paired_irks')
.then(r => r.json())
.then(data => {
if (btn) btn.disabled = false;
const devices = data.devices || [];
if (devices.length === 0) {
status.textContent = 'No paired devices with IRKs found';
return;
}
status.style.display = 'none';
list.innerHTML = '';
devices.forEach(dev => {
const item = document.createElement('div');
item.className = 'btl-irk-picker-item';
item.innerHTML =
'<div class="btl-irk-picker-name">' + (dev.name || 'Unknown Device') + '</div>' +
'<div class="btl-irk-picker-meta">' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '</div>';
item.addEventListener('click', function() {
selectPairedIrk(dev);
});
list.appendChild(item);
});
})
.catch(err => {
if (btn) btn.disabled = false;
console.error('[BtLocate] Failed to fetch paired IRKs:', err);
status.textContent = 'Failed to read paired devices';
});
}
function selectPairedIrk(dev) {
const irkInput = document.getElementById('btLocateIrk');
const nameInput = document.getElementById('btLocateNamePattern');
const picker = document.getElementById('btLocateIrkPicker');
if (irkInput) irkInput.value = dev.irk_hex;
if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name;
if (picker) picker.style.display = 'none';
}
function clearTrail() {
fetch('/bt_locate/clear_trail', { method: 'POST' })
.then(r => r.json())
.then(() => {
clearMapMarkers();
rssiHistory = [];
drawRssiChart();
updateStats(0, 0);
})
.catch(err => console.error('[BtLocate] Clear trail error:', err));
}
function invalidateMap() {
if (map) map.invalidateSize();
}
return {
init,
start,
stop,
handoff,
clearHandoff,
setEnvironment,
toggleAudio,
clearTrail,
handleDetection,
invalidateMap,
fetchPairedIrks,
};
})();
+94 -14
View File
@@ -17,6 +17,7 @@ let dmrHasAudio = false;
let dmrBookmarks = [];
const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
const DMR_SETTINGS_KEY = 'dmrSettings';
const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']);
// ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null;
@@ -44,9 +45,16 @@ function checkDmrTools() {
const warningText = document.getElementById('dmrToolsWarningText');
if (!warning) return;
const selectedType = (typeof getSelectedSDRType === 'function')
? getSelectedSDRType()
: 'rtlsdr';
const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
if (selectedType === 'rtlsdr') {
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
} else if (!data.rx_fm) {
missing.push('rx_fm (SoapySDR demodulator)');
}
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
if (missing.length > 0) {
@@ -71,6 +79,7 @@ function startDmr() {
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
@@ -90,7 +99,7 @@ function startDmr() {
fetch('/dmr/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc })
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
})
.then(r => r.json())
.then(data => {
@@ -630,7 +639,26 @@ function restoreDmrSettings() {
function loadDmrBookmarks() {
try {
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
dmrBookmarks = saved ? JSON.parse(saved) : [];
const parsed = saved ? JSON.parse(saved) : [];
if (!Array.isArray(parsed)) {
dmrBookmarks = [];
} else {
dmrBookmarks = parsed
.map((entry) => {
const freq = Number(entry?.freq);
if (!Number.isFinite(freq) || freq <= 0) return null;
const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol);
const rawLabel = String(entry?.label || '').trim();
const label = rawLabel || `${freq.toFixed(4)} MHz`;
return {
freq,
protocol,
label,
added: entry?.added,
};
})
.filter(Boolean);
}
} catch (e) {
dmrBookmarks = [];
}
@@ -643,6 +671,11 @@ function saveDmrBookmarks() {
} catch (e) { /* localStorage unavailable */ }
}
function sanitizeDmrBookmarkProtocol(protocol) {
const value = String(protocol || 'auto').toLowerCase();
return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto';
}
function addDmrBookmark() {
const freqInput = document.getElementById('dmrBookmarkFreq');
const labelInput = document.getElementById('dmrBookmarkLabel');
@@ -656,7 +689,7 @@ function addDmrBookmark() {
return;
}
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto');
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
// Duplicate check
@@ -696,26 +729,73 @@ function removeDmrBookmark(index) {
function dmrQuickTune(freq, protocol) {
const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol');
if (freqEl) freqEl.value = freq;
if (protoEl) protoEl.value = protocol;
if (freqEl && Number.isFinite(freq)) freqEl.value = freq;
if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol);
}
function renderDmrBookmarks() {
const container = document.getElementById('dmrBookmarksList');
if (!container) return;
container.replaceChildren();
if (dmrBookmarks.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>';
const emptyEl = document.createElement('div');
emptyEl.style.color = 'var(--text-muted)';
emptyEl.style.textAlign = 'center';
emptyEl.style.padding = '10px';
emptyEl.style.fontSize = '11px';
emptyEl.textContent = 'No bookmarks saved';
container.appendChild(emptyEl);
return;
}
container.innerHTML = dmrBookmarks.map((b, i) => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 3px; margin-bottom: 3px;">
<span style="cursor: pointer; color: var(--accent-cyan); font-size: 11px; flex: 1;" onclick="dmrQuickTune(${b.freq}, '${b.protocol}')" title="${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})">${b.label}</span>
<span style="color: var(--text-muted); font-size: 9px; margin: 0 6px;">${b.protocol.toUpperCase()}</span>
<button onclick="removeDmrBookmark(${i})" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 0 4px;">&times;</button>
</div>
`).join('');
dmrBookmarks.forEach((b, i) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.padding = '4px 6px';
row.style.background = 'rgba(0,0,0,0.2)';
row.style.borderRadius = '3px';
row.style.marginBottom = '3px';
const tuneBtn = document.createElement('button');
tuneBtn.type = 'button';
tuneBtn.style.cursor = 'pointer';
tuneBtn.style.color = 'var(--accent-cyan)';
tuneBtn.style.fontSize = '11px';
tuneBtn.style.flex = '1';
tuneBtn.style.background = 'none';
tuneBtn.style.border = 'none';
tuneBtn.style.textAlign = 'left';
tuneBtn.style.padding = '0';
tuneBtn.textContent = b.label;
tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`;
tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol));
const protocolEl = document.createElement('span');
protocolEl.style.color = 'var(--text-muted)';
protocolEl.style.fontSize = '9px';
protocolEl.style.margin = '0 6px';
protocolEl.textContent = b.protocol.toUpperCase();
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.style.background = 'none';
deleteBtn.style.border = 'none';
deleteBtn.style.color = 'var(--accent-red)';
deleteBtn.style.cursor = 'pointer';
deleteBtn.style.fontSize = '12px';
deleteBtn.style.padding = '0 4px';
deleteBtn.textContent = '\u00d7';
deleteBtn.addEventListener('click', () => removeDmrBookmark(i));
row.appendChild(tuneBtn);
row.appendChild(protocolEl);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
// ============== STATUS SYNC ==============
+401
View File
@@ -0,0 +1,401 @@
/**
* GPS Mode
* Live GPS data display with satellite sky view, signal strength bars,
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
*/
const GPS = (function() {
let eventSource = null;
let connected = false;
let lastPosition = null;
let lastSky = null;
// Constellation color map
const CONST_COLORS = {
'GPS': '#00d4ff',
'GLONASS': '#00ff88',
'Galileo': '#ff8800',
'BeiDou': '#ff4466',
'SBAS': '#ffdd00',
'QZSS': '#cc66ff',
};
function init() {
drawEmptySkyView();
connect();
}
function connect() {
fetch('/gps/auto-connect', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.status === 'connected') {
connected = true;
updateConnectionUI(true, data.has_fix);
if (data.position) {
lastPosition = data.position;
updatePositionUI(data.position);
}
if (data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
startStream();
} else {
connected = false;
updateConnectionUI(false);
}
})
.catch(() => {
connected = false;
updateConnectionUI(false);
});
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
fetch('/gps/stop', { method: 'POST' })
.then(() => {
connected = false;
updateConnectionUI(false);
});
}
function startStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/gps/stream');
eventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'position') {
lastPosition = data;
updatePositionUI(data);
updateConnectionUI(true, true);
} else if (data.type === 'sky') {
lastSky = data;
updateSkyUI(data);
}
} catch (err) {
// ignore parse errors
}
};
eventSource.onerror = function() {
// Reconnect handled by browser automatically
};
}
// ========================
// UI Updates
// ========================
function updateConnectionUI(isConnected, hasFix) {
const dot = document.getElementById('gpsStatusDot');
const text = document.getElementById('gpsStatusText');
const connectBtn = document.getElementById('gpsConnectBtn');
const disconnectBtn = document.getElementById('gpsDisconnectBtn');
const devicePath = document.getElementById('gpsDevicePath');
if (dot) {
dot.className = 'gps-status-dot';
if (isConnected && hasFix) dot.classList.add('connected');
else if (isConnected) dot.classList.add('waiting');
}
if (text) {
if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
else if (isConnected) text.textContent = 'Connected (No Fix)';
else text.textContent = 'Disconnected';
}
if (connectBtn) connectBtn.style.display = isConnected ? 'none' : '';
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
}
function updatePositionUI(pos) {
// Sidebar fields
setText('gpsLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
setText('gpsLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
setText('gpsAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
setText('gpsSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
setText('gpsHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
setText('gpsClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
// Fix type
const fixEl = document.getElementById('gpsFixType');
if (fixEl) {
const fq = pos.fix_quality;
if (fq === 3) fixEl.innerHTML = '<span class="gps-fix-badge fix-3d">3D FIX</span>';
else if (fq === 2) fixEl.innerHTML = '<span class="gps-fix-badge fix-2d">2D FIX</span>';
else fixEl.innerHTML = '<span class="gps-fix-badge no-fix">NO FIX</span>';
}
// Error estimates
const eph = (pos.epx != null && pos.epy != null) ? Math.sqrt(pos.epx * pos.epx + pos.epy * pos.epy) : null;
setText('gpsEph', eph != null ? eph.toFixed(1) + ' m' : '---');
setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---');
setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---');
// GPS time
if (pos.timestamp) {
const t = new Date(pos.timestamp);
setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
}
// Visuals: position panel
setText('gpsVisPosLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
setText('gpsVisPosLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
setText('gpsVisPosAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
setText('gpsVisPosSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
setText('gpsVisPosHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
setText('gpsVisPosClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
// Visuals: fix badge
const visFixEl = document.getElementById('gpsVisFixBadge');
if (visFixEl) {
const fq = pos.fix_quality;
if (fq === 3) { visFixEl.textContent = '3D FIX'; visFixEl.className = 'gps-fix-badge fix-3d'; }
else if (fq === 2) { visFixEl.textContent = '2D FIX'; visFixEl.className = 'gps-fix-badge fix-2d'; }
else { visFixEl.textContent = 'NO FIX'; visFixEl.className = 'gps-fix-badge no-fix'; }
}
// Visuals: GPS time
if (pos.timestamp) {
const t = new Date(pos.timestamp);
setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
}
}
function updateSkyUI(sky) {
// Sidebar sat counts
setText('gpsSatUsed', sky.usat != null ? sky.usat : '-');
setText('gpsSatTotal', sky.nsat != null ? sky.nsat : '-');
// DOP values
setDop('gpsHdop', sky.hdop);
setDop('gpsVdop', sky.vdop);
setDop('gpsPdop', sky.pdop);
setDop('gpsTdop', sky.tdop);
setDop('gpsGdop', sky.gdop);
// Visuals
drawSkyView(sky.satellites || []);
drawSignalBars(sky.satellites || []);
}
function setDop(id, val) {
const el = document.getElementById(id);
if (!el) return;
if (val == null) { el.textContent = '---'; el.className = 'gps-info-value gps-mono'; return; }
el.textContent = val.toFixed(1);
let cls = 'gps-info-value gps-mono ';
if (val <= 2) cls += 'gps-dop-good';
else if (val <= 5) cls += 'gps-dop-moderate';
else cls += 'gps-dop-poor';
el.className = cls;
}
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
// ========================
// Sky View Polar Plot
// ========================
function drawEmptySkyView() {
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
drawSkyViewBase(canvas);
}
function drawSkyView(satellites) {
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
drawSkyViewBase(canvas);
// Plot satellites
satellites.forEach(sat => {
if (sat.elevation == null || sat.azimuth == null) return;
const elRad = (90 - sat.elevation) / 90;
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
const px = cx + r * elRad * Math.cos(azRad);
const py = cy + r * elRad * Math.sin(azRad);
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
const dotSize = sat.used ? 6 : 4;
// Draw dot
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
if (sat.used) {
ctx.fillStyle = color;
ctx.fill();
} else {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// PRN label
ctx.fillStyle = color;
ctx.font = '8px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sat.prn, px, py - dotSize - 2);
// SNR value
if (sat.snr != null) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '7px JetBrains Mono, monospace';
ctx.textBaseline = 'top';
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
}
});
}
function drawSkyViewBase(canvas) {
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
ctx.clearRect(0, 0, w, h);
// Background
const bgStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-card').trim();
ctx.fillStyle = bgStyle || '#0d1117';
ctx.fillRect(0, 0, w, h);
// Elevation rings (0, 30, 60, 90)
ctx.strokeStyle = '#2a3040';
ctx.lineWidth = 0.5;
[90, 60, 30].forEach(el => {
const gr = r * (1 - el / 90);
ctx.beginPath();
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
ctx.stroke();
// Label
ctx.fillStyle = '#555';
ctx.font = '9px JetBrains Mono, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
});
// Horizon circle
ctx.strokeStyle = '#3a4050';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Cardinal directions
ctx.fillStyle = '#888';
ctx.font = 'bold 11px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', cx, cy - r - 12);
ctx.fillText('S', cx, cy + r + 12);
ctx.fillText('E', cx + r + 12, cy);
ctx.fillText('W', cx - r - 12, cy);
// Crosshairs
ctx.strokeStyle = '#2a3040';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx, cy + r);
ctx.moveTo(cx - r, cy);
ctx.lineTo(cx + r, cy);
ctx.stroke();
// Zenith dot
ctx.fillStyle = '#333';
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
ctx.fill();
}
// ========================
// Signal Strength Bars
// ========================
function drawSignalBars(satellites) {
const container = document.getElementById('gpsSignalBars');
if (!container) return;
container.innerHTML = '';
if (satellites.length === 0) return;
// Sort: used first, then by PRN
const sorted = [...satellites].sort((a, b) => {
if (a.used !== b.used) return a.used ? -1 : 1;
return a.prn - b.prn;
});
const maxSnr = 50; // dB-Hz typical max for display
sorted.forEach(sat => {
const snr = sat.snr || 0;
const heightPct = Math.min(snr / maxSnr * 100, 100);
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
const constClass = 'gps-const-' + (sat.constellation || 'GPS').toLowerCase();
const wrap = document.createElement('div');
wrap.className = 'gps-signal-bar-wrap';
const snrLabel = document.createElement('span');
snrLabel.className = 'gps-signal-snr';
snrLabel.textContent = snr > 0 ? Math.round(snr) : '';
const bar = document.createElement('div');
bar.className = 'gps-signal-bar ' + constClass + (sat.used ? '' : ' unused');
bar.style.height = Math.max(heightPct, 2) + '%';
bar.title = `PRN ${sat.prn} (${sat.constellation}) - ${Math.round(snr)} dB-Hz${sat.used ? ' [USED]' : ''}`;
const prn = document.createElement('span');
prn.className = 'gps-signal-prn';
prn.textContent = sat.prn;
wrap.appendChild(snrLabel);
wrap.appendChild(bar);
wrap.appendChild(prn);
container.appendChild(wrap);
});
}
// ========================
// Cleanup
// ========================
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
return {
init: init,
connect: connect,
disconnect: disconnect,
destroy: destroy,
};
})();
+6 -30
View File
@@ -1742,9 +1742,6 @@ function initSynthesizer() {
drawSynthesizer();
}
// Debug: log signal level periodically
let lastSynthDebugLog = 0;
function drawSynthesizer() {
if (!synthCtx || !synthCanvas) return;
@@ -1760,19 +1757,6 @@ function drawSynthesizer() {
let activityLevel = 0;
let signalIntensity = 0;
// Debug logging every 2 seconds
const now = Date.now();
if (now - lastSynthDebugLog > 2000) {
console.log('[SYNTH] State:', {
isScannerRunning,
isDirectListening,
scannerSignalActive,
currentSignalLevel,
visualizerAnalyser: !!visualizerAnalyser
});
lastSynthDebugLog = now;
}
if (isScannerRunning && !isScannerPaused) {
// Use actual signal level data (0-5000 range, normalize to 0-1)
signalIntensity = Math.min(1, currentSignalLevel / 3000);
@@ -1864,13 +1848,6 @@ function drawSynthesizer() {
synthCtx.lineTo(width, height / 2);
synthCtx.stroke();
// Debug: show signal level value
if (isScannerRunning || isDirectListening) {
synthCtx.fillStyle = 'rgba(255, 255, 255, 0.5)';
synthCtx.font = '9px monospace';
synthCtx.fillText(`lvl:${Math.round(currentSignalLevel)}`, 4, 10);
}
synthAnimationId = requestAnimationFrame(drawSynthesizer);
}
@@ -3109,7 +3086,7 @@ let waterfallEndFreq = 108;
let waterfallRowImage = null;
let waterfallPalette = null;
let lastWaterfallDraw = 0;
const WATERFALL_MIN_INTERVAL_MS = 50;
const WATERFALL_MIN_INTERVAL_MS = 200;
let waterfallInteractionBound = false;
let waterfallResizeObserver = null;
let waterfallMode = 'rf';
@@ -3296,7 +3273,7 @@ async function syncWaterfallToFrequency(freq, options = {}) {
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
@@ -3341,7 +3318,7 @@ async function zoomWaterfall(direction) {
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
@@ -3436,9 +3413,8 @@ function startAudioWaterfall() {
if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) {
lastAudioWaterfallDraw = ts;
visualizerAnalyser.getByteFrequencyData(dataArray);
const bins = Array.from(dataArray, v => v);
drawWaterfallRow(bins);
drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz');
drawWaterfallRow(dataArray);
drawSpectrumLine(dataArray, 0, maxFreqKhz, 'kHz');
}
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
};
@@ -3841,7 +3817,7 @@ async function startWaterfall(options = {}) {
span_mhz: spanMhz,
gain: gain,
device: device,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fftSize,
fps: 25,
avg_count: 4,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -160,7 +160,7 @@ const WeatherSat = (function() {
const biasTInput = document.getElementById('weatherSatBiasT');
const deviceSelect = document.getElementById('deviceSelect');
const satellite = satSelect?.value || 'NOAA-18';
const satellite = satSelect?.value || 'METEOR-M2-3';
const gain = parseFloat(gainInput?.value || '40');
const biasT = biasTInput?.checked || false;
const device = parseInt(deviceSelect?.value || '0', 10);
@@ -237,7 +237,7 @@ const WeatherSat = (function() {
const fileInput = document.getElementById('wxsatTestFilePath');
const rateSelect = document.getElementById('wxsatTestSampleRate');
const satellite = satSelect?.value || 'NOAA-18';
const satellite = satSelect?.value || 'METEOR-M2-3';
const inputFile = (fileInput?.value || '').trim();
const sampleRate = parseInt(rateSelect?.value || '1000000', 10);
-2
View File
@@ -2537,7 +2537,6 @@ sudo make install</code>
// Check for remote dump1090 config (only for local mode)
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
if (remoteConfig === false) return;
// Check for agent SDR conflicts
if (useAgent && typeof checkAgentModeConflict === 'function') {
if (!checkAgentModeConflict('adsb')) {
@@ -2556,7 +2555,6 @@ sudo make install</code>
requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port;
}
try {
// Route through agent proxy if using remote agent
const url = useAgent
+675 -76
View File
File diff suppressed because it is too large Load Diff
+12 -14
View File
@@ -1,20 +1,18 @@
<!-- BLUETOOTH MODE -->
<div id="bluetoothMode" class="mode-content">
<!-- Capability Status -->
<div id="btCapabilityStatus" class="section" style="display: none;">
<!-- Populated by JavaScript with capability warnings -->
</div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" class="section" style="display: none; padding: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="section">
<h3>Scanner Configuration</h3>
<!-- Populated by JavaScript with capability warnings -->
<div id="btCapabilityStatus" style="display: none; margin-bottom: 8px;"></div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" style="display: none; margin-bottom: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="form-group">
<label>Adapter</label>
<select id="btAdapterSelect">
@@ -61,7 +59,7 @@
Stop Scanning
</button>
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">
+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>
+16 -9
View File
@@ -2,6 +2,9 @@
<div id="dmrMode" class="mode-content">
<div class="section">
<h3>Digital Voice</h3>
<div class="alpha-mode-notice">
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
</div>
<!-- Dependency Warning -->
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
@@ -19,13 +22,16 @@
<div class="form-group">
<label>Protocol</label>
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option>
<option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
For NXDN and ProVoice, use manual protocol selection for best lock reliability
</span>
</div>
<div class="form-group">
@@ -70,14 +76,6 @@
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none; margin-top: 12px;">
Stop Decoder
</button>
<!-- Current Call -->
<div class="section" style="margin-top: 12px;">
<h3>Current Call</h3>
@@ -104,4 +102,13 @@
</div>
</div>
</div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
Stop Decoder
</button>
</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>
+175
View File
@@ -0,0 +1,175 @@
<!-- SUBGHZ TRANSCEIVER MODE -->
<div id="subghzMode" class="mode-content">
<div class="section">
<h3>SubGHz Transceiver</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
HackRF One SubGHz transceiver. Capture raw signals, replay saved bursts,
and scan wideband activity with frequency analysis.
</p>
</div>
<!-- Device -->
<div class="section">
<h3>HackRF Device</h3>
<div class="subghz-device-status" id="subghzDeviceStatus">
<div class="subghz-device-row">
<span class="subghz-device-dot" id="subghzDeviceDot"></span>
<span class="subghz-device-label" id="subghzDeviceLabel">Checking...</span>
</div>
<div class="subghz-device-tools" id="subghzDeviceTools">
<span class="subghz-tool-badge" id="subghzToolHackrf" title="hackrf_transfer">HackRF</span>
<span class="subghz-tool-badge" id="subghzToolSweep" title="hackrf_sweep">Sweep</span>
</div>
</div>
<div class="form-group" style="margin-top: 8px;">
<label>Device Serial <span style="color: var(--text-dim); font-weight: normal;">(optional)</span></label>
<input type="text" id="subghzDeviceSerial" placeholder="auto-detect" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
</div>
</div>
<!-- Status -->
<div class="subghz-status-row" id="subghzStatusRow">
<div class="subghz-status-dot" id="subghzStatusDot"></div>
<span class="subghz-status-text" id="subghzStatusText">Idle</span>
<span class="subghz-status-timer" id="subghzStatusTimer"></span>
</div>
<!-- Frequency -->
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="subghzFrequency" value="433.92" step="0.001" min="1" max="6000">
</div>
<div class="subghz-preset-btns">
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(315)">315M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(433.92)">433.92M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(868)">868M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(915)">915M</button>
</div>
</div>
<!-- Gain -->
<div class="section">
<h3>Gain</h3>
<div class="form-group">
<label>LNA Gain (0-40 dB)</label>
<input type="range" id="subghzLnaGain" min="0" max="40" value="24" step="8" oninput="document.getElementById('subghzLnaVal').textContent=this.value">
<span id="subghzLnaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">24</span>
</div>
<div class="form-group">
<label>VGA Gain (0-62 dB)</label>
<input type="range" id="subghzVgaGain" min="0" max="62" value="20" step="2" oninput="document.getElementById('subghzVgaVal').textContent=this.value">
<span id="subghzVgaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="subghzSampleRate" class="mode-select">
<option value="2000000" selected>2 MHz</option>
<option value="4000000">4 MHz</option>
<option value="8000000">8 MHz</option>
<option value="10000000">10 MHz</option>
<option value="20000000">20 MHz</option>
</select>
</div>
</div>
<!-- Tabs: Receive RAW / Sweep -->
<div class="section">
<div class="subghz-tabs">
<button class="subghz-tab active" data-tab="rx" onclick="SubGhz.switchTab('rx')">Read RAW</button>
<button class="subghz-tab" data-tab="sweep" onclick="SubGhz.switchTab('sweep')">Sweep</button>
</div>
<!-- RX Tab -->
<div class="subghz-tab-content active" id="subghzTabRx">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Capture raw IQ data to file. Saved captures can be replayed or analyzed.
</p>
<div class="subghz-trigger-box">
<label class="subghz-trigger-toggle">
<input type="checkbox" id="subghzTriggerEnabled" onchange="SubGhz.syncTriggerControls()">
Smart Trigger Capture
</label>
<div class="subghz-trigger-grid">
<label>Pre-roll (ms)</label>
<input type="number" id="subghzTriggerPreMs" min="50" max="5000" step="50" value="350">
<label>Post-roll (ms)</label>
<input type="number" id="subghzTriggerPostMs" min="100" max="10000" step="50" value="700">
</div>
<p class="subghz-trigger-help">Auto-stops after burst + post-roll and trims capture window.</p>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzRxStartBtn" onclick="SubGhz.startRx()">Start Capture</button>
<button class="subghz-btn stop" id="subghzRxStopBtn" onclick="SubGhz.stopRx()" disabled>Stop Capture</button>
</div>
</div>
<!-- Sweep Tab -->
<div class="subghz-tab-content" id="subghzTabSweep">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Wideband spectrum analyzer using hackrf_sweep.
</p>
<div class="form-group">
<label>Frequency Range (MHz)</label>
<div class="subghz-sweep-range">
<input type="number" id="subghzSweepStart" value="300" min="1" max="6000" step="1">
<span>to</span>
<input type="number" id="subghzSweepEnd" value="928" min="1" max="6000" step="1">
</div>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzSweepStartBtn" onclick="SubGhz.startSweep()">Start Sweep</button>
<button class="subghz-btn stop" id="subghzSweepStopBtn" onclick="SubGhz.stopSweep()" disabled>Stop Sweep</button>
</div>
<div style="margin-top: 10px;">
<label style="font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px;">Detected Peaks</label>
<div class="subghz-peak-list" id="subghzPeakList"></div>
</div>
</div>
</div>
<!-- TX Settings (collapsible) -->
<div class="section">
<h3 style="cursor: pointer;" onclick="document.getElementById('subghzTxSection').classList.toggle('active')">
Transmit Settings <span style="font-size: 10px; color: var(--text-dim);">&#9660;</span>
</h3>
<div id="subghzTxSection" style="display: none;">
<div class="subghz-tx-warning">
WARNING: Transmitting radio signals may be illegal without proper authorization.
Only transmit on frequencies you are licensed for and within ISM band limits.
TX is restricted to ISM bands: 300-348, 387-464, 779-928 MHz.
</div>
<div class="form-group">
<label>TX VGA Gain (0-47 dB)</label>
<input type="range" id="subghzTxGain" min="0" max="47" value="20" step="1" oninput="document.getElementById('subghzTxGainVal').textContent=this.value">
<span id="subghzTxGainVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Max Duration (seconds)</label>
<input type="number" id="subghzTxMaxDuration" value="10" min="1" max="30" step="1">
</div>
</div>
</div>
<!-- Saved Signals Library -->
<div class="section">
<h3>Saved Signals</h3>
<div class="subghz-captures-list" id="subghzSidebarCaptures" style="max-height: 220px; overflow-y: auto;">
<div class="subghz-empty" id="subghzSidebarCapturesEmpty">No saved captures yet</div>
</div>
</div>
</div>
<script>
// Toggle TX section visibility
document.addEventListener('DOMContentLoaded', function() {
const h3 = document.querySelector('#subghzTxSection')?.previousElementSibling;
if (h3) {
h3.addEventListener('click', function() {
const section = document.getElementById('subghzTxSection');
if (section) section.style.display = section.style.display === 'none' ? 'block' : 'none';
});
}
});
</script>
+14 -13
View File
@@ -2,7 +2,7 @@
<div id="tscmMode" class="mode-content">
<!-- Configuration -->
<div class="section">
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<h3>TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<div class="form-group">
<label>Sweep Type</label>
@@ -65,14 +65,6 @@
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()" style="margin-top: 12px;">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none; margin-top: 12px;">
Stop Sweep
</button>
<!-- Futuristic Scanner Progress -->
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
<div class="scanner-ring">
@@ -115,8 +107,8 @@
</div>
<!-- Advanced -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 12px;">Advanced</h3>
<div class="section">
<h3>Advanced</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Baseline Recording</label>
@@ -156,8 +148,8 @@
</div>
<!-- Tools -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 10px;">Tools</h3>
<div class="section">
<h3>Tools</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities
@@ -182,4 +174,13 @@
<!-- Device Warnings -->
<div id="tscmDeviceWarnings" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255,153,51,0.1); border: 1px solid rgba(255,153,51,0.3); border-radius: 4px;"></div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none;">
Stop Sweep
</button>
</div>
</div>
+19 -14
View File
@@ -1,22 +1,26 @@
<!-- WEATHER SATELLITE MODE -->
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing.
</p>
</div>
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing.
</p>
</div>
<div class="section">
<h3>Satellite</h3>
<div class="form-group">
<label>Select Satellite</label>
<select id="weatherSatSelect" class="mode-select">
<option value="NOAA-15">NOAA-15 (137.620 MHz APT)</option>
<option value="NOAA-18" selected>NOAA-18 (137.9125 MHz APT)</option>
<option value="NOAA-19">NOAA-19 (137.100 MHz APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option>
<option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option>
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
</select>
</div>
<div class="form-group">
@@ -187,10 +191,11 @@
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18" selected>NOAA-18 (APT)</option>
<option value="NOAA-18">NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (LRPT)</option>
</select>
</div>
<div class="form-group">
+26 -23
View File
@@ -1,7 +1,8 @@
<!-- WiFi MODE -->
<div id="wifiMode" class="mode-content">
<!-- Scan Mode Tabs -->
<div class="section" style="padding: 8px;">
<div class="section">
<h3>Signal Source</h3>
<div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;">
<button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;">
Quick Scan
@@ -168,29 +169,8 @@
</div>
</div>
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
<!-- Export Section -->
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
@@ -201,4 +181,27 @@
</button>
</div>
</div>
<div class="mode-actions-bottom">
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
</div>
</div>
+6 -2
View File
@@ -71,8 +71,8 @@
{{ mode_item('listening', 'Listening Post', '<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"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mode_item('dmr', 'Digital Voice', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</div>
</div>
@@ -87,6 +87,7 @@
<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('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>
@@ -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('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('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>
@@ -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('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('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>') }}
{% 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>') }}
@@ -188,11 +191,12 @@
{{ 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('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('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('dmr', 'DMR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}
-1
View File
@@ -323,7 +323,6 @@
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>
<option value="dmr">DMR</option>
<option value="tscm">TSCM</option>
<option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option>
+34 -11
View File
@@ -107,12 +107,8 @@
<label>TARGET:</label>
<select id="satSelect" onchange="onSatelliteChange()">
<option value="25544">ISS (ZARYA)</option>
<option value="25338">NOAA 15</option>
<option value="28654">NOAA 18</option>
<option value="33591">NOAA 19</option>
<option value="40069">METEOR-M2</option>
<option value="43013">NOAA 20</option>
<option value="54234">METEOR-M2-3</option>
<option value="57166">METEOR-M2-3</option>
</select>
</div>
@@ -273,16 +269,42 @@
let currentLocationSource = 'local';
let agents = [];
const satellites = {
let satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
25338: { name: 'NOAA 15', color: '#00ff00' },
28654: { name: 'NOAA 18', color: '#ff6600' },
33591: { name: 'NOAA 19', color: '#ff3366' },
40069: { name: 'METEOR-M2', color: '#9370DB' },
43013: { name: 'NOAA 20', color: '#00ffaa' },
54234: { 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() {
const select = document.getElementById('satSelect');
selectedSatellite = parseInt(select.value);
@@ -339,6 +361,7 @@
}
document.addEventListener('DOMContentLoaded', () => {
loadDashboardSatellites();
setupEmbeddedMode();
const usedShared = applySharedObserverLocation();
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()
+78 -4
View File
@@ -1,7 +1,9 @@
"""Tests for the DMR / Digital Voice decoding module."""
import queue
from unittest.mock import patch, MagicMock
import pytest
import routes.dmr as dmr_module
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
@@ -132,9 +134,9 @@ def test_dsd_fme_flags_differ_from_classic():
def test_dsd_fme_protocol_flags_known_values():
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
@@ -153,9 +155,9 @@ def test_dsd_protocol_flags_known_values():
def test_dsd_fme_modulation_hints():
"""C4FM modulation hints should be set for C4FM protocols."""
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
assert _DSD_FME_MODULATION['p25'] == ['-mc']
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
# D-Star and ProVoice should not have forced modulation
# P25, D-Star and ProVoice should not have forced modulation
assert 'p25' not in _DSD_FME_MODULATION
assert 'dstar' not in _DSD_FME_MODULATION
assert 'provoice' not in _DSD_FME_MODULATION
@@ -172,6 +174,40 @@ def auth_client(client):
return client
@pytest.fixture(autouse=True)
def reset_dmr_globals():
"""Reset DMR globals before/after each test to avoid cross-test bleed."""
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
yield
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
def test_dmr_tools(auth_client):
"""Tools endpoint should return availability info."""
resp = auth_client.get('/dmr/tools')
@@ -235,3 +271,41 @@ def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream')
def test_dmr_start_exception_cleans_up_resources(auth_client):
"""If startup fails after rtl_fm launch, process/device state should be reset."""
rtl_proc = MagicMock()
rtl_proc.poll.return_value = None
rtl_proc.wait.return_value = 0
rtl_proc.stdout = MagicMock()
rtl_proc.stderr = MagicMock()
builder = MagicMock()
builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M']
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \
patch('routes.dmr.find_ffmpeg', return_value=None), \
patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \
patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \
patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \
patch('routes.dmr.app_module.release_sdr_device') as release_mock, \
patch('routes.dmr.register_process') as register_mock, \
patch('routes.dmr.unregister_process') as unregister_mock, \
patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
'device': 0,
})
assert resp.status_code == 500
assert 'dsd launch failed' in resp.get_json()['message']
register_mock.assert_called_once_with(rtl_proc)
rtl_proc.terminate.assert_called_once()
unregister_mock.assert_called_once_with(rtl_proc)
release_mock.assert_called_once_with(0)
assert dmr_module.dmr_running is False
assert dmr_module.dmr_rtl_process is None
assert dmr_module.dmr_dsd_process is None
+8 -5
View File
@@ -313,8 +313,9 @@ class TestDSCDecoder:
def test_decode_mmsi_with_leading_zeros(self, decoder):
"""Test MMSI decoding handles leading zeros."""
# Coast station: 002320001
# 00-23-20-00-01 -> [0, 23, 20, 0, 1]
symbols = [0, 23, 20, 0, 1]
# Padded to 10 digits: 0002320001
# BCD pairs: 00-02-32-00-01 -> [0, 2, 32, 0, 1]
symbols = [0, 2, 32, 0, 1]
result = decoder._decode_mmsi(symbols)
assert result == '002320001'
@@ -328,14 +329,16 @@ class TestDSCDecoder:
# Symbols > 99 should be treated as 0
symbols = [100, 32, 12, 34, 56]
result = decoder._decode_mmsi(symbols)
# First symbol becomes 00
assert result == '003212345'[-9:]
# First symbol (100) becomes 00, padded result "0032123456",
# trim leading pad digit -> "032123456"
assert result == '032123456'
def test_decode_position_northeast(self, decoder):
"""Test position decoding for NE quadrant."""
# Quadrant 10 = NE (lat+, lon+)
# 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)
assert result is not None
+20 -10
View File
@@ -46,20 +46,30 @@ class TestHealthEndpoint:
assert 'processes' in data
assert 'data' in data
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
processes = data['processes']
assert 'pager' in processes
assert 'sensor' in processes
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
class TestDevicesEndpoint:
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
def test_health_reports_dmr_route_process(self, client):
"""Health should reflect DMR route module state (not stale app globals)."""
mock_proc = MagicMock()
mock_proc.poll.return_value = None
with patch('routes.dmr.dmr_running', True), \
patch('routes.dmr.dmr_dsd_process', mock_proc):
response = client.get('/health')
data = json.loads(response.data)
assert data['processes']['dmr'] is True
class TestDevicesEndpoint:
"""Tests for devices endpoint."""
def test_get_devices(self, client):
+38
View File
@@ -0,0 +1,38 @@
"""Tests for rtl_fm modulation token mapping."""
from routes.listening_post import _rtl_fm_demod_mode as listening_post_rtl_mode
from utils.sdr.base import SDRDevice, SDRType
from utils.sdr.rtlsdr import RTLSDRCommandBuilder, _rtl_fm_demod_mode as builder_rtl_mode
def _dummy_rtlsdr_device() -> SDRDevice:
return SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=0,
name='RTL-SDR',
serial='00000001',
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES,
)
def test_rtl_fm_modulation_maps_wfm_to_wbfm() -> None:
assert listening_post_rtl_mode('wfm') == 'wbfm'
assert builder_rtl_mode('wfm') == 'wbfm'
def test_rtl_fm_modulation_keeps_other_modes() -> None:
assert listening_post_rtl_mode('fm') == 'fm'
assert builder_rtl_mode('am') == 'am'
def test_rtlsdr_builder_uses_wbfm_token_for_wfm() -> None:
builder = RTLSDRCommandBuilder()
cmd = builder.build_fm_demod_command(
device=_dummy_rtlsdr_device(),
frequency_mhz=98.1,
modulation='wfm',
)
mode_index = cmd.index('-M')
assert cmd[mode_index + 1] == 'wbfm'
+608
View File
@@ -0,0 +1,608 @@
"""Tests for SubGhzManager utility module."""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzManager, SubGhzCapture
@pytest.fixture
def tmp_data_dir(tmp_path):
"""Create a temporary data directory for SubGhz captures."""
data_dir = tmp_path / 'subghz'
data_dir.mkdir()
(data_dir / 'captures').mkdir()
return data_dir
@pytest.fixture
def manager(tmp_data_dir):
"""Create a SubGhzManager with temp directory."""
return SubGhzManager(data_dir=tmp_data_dir)
class TestSubGhzManagerInit:
def test_creates_data_dirs(self, tmp_path):
data_dir = tmp_path / 'new_subghz'
mgr = SubGhzManager(data_dir=data_dir)
assert (data_dir / 'captures').is_dir()
def test_active_mode_idle(self, manager):
assert manager.active_mode == 'idle'
def test_get_status_idle(self, manager):
status = manager.get_status()
assert status['mode'] == 'idle'
class TestToolDetection:
def test_check_hackrf_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'):
assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/rtl_433'):
assert manager.check_rtl433() is True
def test_check_sweep_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'):
assert manager.check_sweep() is True
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_start_receive_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b'')
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
result = manager.start_receive(
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'rx'
def test_start_receive_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_stop_receive_not_running(self, manager):
result = manager.stop_receive()
assert result['status'] == 'not_running'
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / 'captures' / 'test.iq'
iq_file.write_bytes(b'\x00' * 1024)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
manager._rx_file = iq_file
manager._rx_frequency_hz = 433920000
manager._rx_sample_rate = 2000000
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
with patch('utils.subghz.safe_terminate'), \
patch('time.time', return_value=1005.0):
result = manager.stop_receive()
assert result['status'] == 'stopped'
assert 'capture' in result
assert result['capture']['frequency_hz'] == 433920000
# Verify JSON sidecar was written
meta_path = iq_file.with_suffix('.json')
assert meta_path.exists()
meta = json.loads(meta_path.read_text())
assert meta['frequency_hz'] == 433920000
assert isinstance(meta.get('bursts'), list)
assert meta['bursts'][0]['peak_level'] == 42
class TestTxSafety:
def test_validate_tx_frequency_ism_433(self):
result = SubGhzManager.validate_tx_frequency(433920000)
assert result is None # Valid
def test_validate_tx_frequency_ism_315(self):
result = SubGhzManager.validate_tx_frequency(315000000)
assert result is None
def test_validate_tx_frequency_ism_915(self):
result = SubGhzManager.validate_tx_frequency(915000000)
assert result is None
def test_validate_tx_frequency_out_of_band(self):
result = SubGhzManager.validate_tx_frequency(100000000) # 100 MHz
assert result is not None
assert 'outside allowed TX bands' in result
def test_validate_tx_frequency_between_bands(self):
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None
def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='nonexistent')
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
# Create a capture with out-of-band frequency
meta = {
'id': 'test123',
'filename': 'test.iq',
'frequency_hz': 100000000, # 100 MHz - out of ISM
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
}
meta_path = tmp_data_dir / 'captures' / 'test.json'
meta_path.write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'outside allowed TX bands' in result['message']
def test_transmit_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
'id': 'seg001',
'filename': 'seg.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 1.0,
'size_bytes': 2000,
}
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'), \
patch('threading.Timer', return_value=mock_timer), \
patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id='seg001',
start_seconds=0.2,
duration_seconds=0.3,
)
assert result['status'] == 'transmitting'
assert result['segment'] is not None
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
class TestCaptureLibrary:
def test_list_captures_empty(self, manager):
captures = manager.list_captures()
assert captures == []
def test_list_captures_with_data(self, manager, tmp_data_dir):
meta = {
'id': 'cap001',
'filename': 'test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 5.0,
'size_bytes': 1024,
'label': 'test capture',
}
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
captures = manager.list_captures()
assert len(captures) == 1
assert captures[0].capture_id == 'cap001'
assert captures[0].label == 'test capture'
def test_get_capture(self, manager, tmp_data_dir):
meta = {
'id': 'cap002',
'filename': 'test2.iq',
'frequency_hz': 315000000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}
(tmp_data_dir / 'captures' / 'test2.json').write_text(json.dumps(meta))
cap = manager.get_capture('cap002')
assert cap is not None
assert cap.frequency_hz == 315000000
def test_get_capture_not_found(self, manager):
cap = manager.get_capture('nonexistent')
assert cap is None
def test_delete_capture(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'delete_me.iq'
meta_path = captures_dir / 'delete_me.json'
iq_path.write_bytes(b'\x00' * 100)
meta_path.write_text(json.dumps({
'id': 'del001',
'filename': 'delete_me.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
assert manager.delete_capture('del001') is True
assert not iq_path.exists()
assert not meta_path.exists()
def test_delete_capture_not_found(self, manager):
assert manager.delete_capture('nonexistent') is False
def test_update_label(self, manager, tmp_data_dir):
meta = {
'id': 'lbl001',
'filename': 'label_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'label': '',
}
meta_path = tmp_data_dir / 'captures' / 'label_test.json'
meta_path.write_text(json.dumps(meta))
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
updated = json.loads(meta_path.read_text())
assert updated['label'] == 'Garage Remote'
assert updated['label_source'] == 'manual'
def test_update_label_not_found(self, manager):
assert manager.update_capture_label('nonexistent', 'test') is False
def test_get_capture_path(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'path_test.iq'
iq_path.write_bytes(b'\x00' * 100)
(captures_dir / 'path_test.json').write_text(json.dumps({
'id': 'pth001',
'filename': 'path_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
path = manager.get_capture_path('pth001')
assert path is not None
assert path.name == 'path_test.iq'
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path('nonexistent') is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'trim_src.iq'
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / 'trim_src.json').write_text(json.dumps({
'id': 'trim001',
'filename': 'trim_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'label': 'Weather Burst',
'bursts': [
{
'start_seconds': 0.55,
'duration_seconds': 0.2,
'peak_level': 67,
'fingerprint': 'abc123',
'modulation_hint': 'OOK/ASK',
'modulation_confidence': 0.9,
}
],
}))
result = manager.trim_capture(
capture_id='trim001',
start_seconds=0.5,
duration_seconds=0.4,
)
assert result['status'] == 'ok'
assert result['capture']['id'] != 'trim001'
assert result['capture']['size_bytes'] == 800
assert result['capture']['label'].endswith('(Trim)')
trimmed_iq = captures_dir / result['capture']['filename']
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix('.json')
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'auto_src.iq'
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / 'auto_src.json').write_text(json.dumps({
'id': 'trim002',
'filename': 'auto_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'bursts': [
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
],
}))
result = manager.trim_capture(capture_id='trim002')
assert result['status'] == 'ok'
assert result['segment']['auto_selected'] is True
assert result['capture']['duration_seconds'] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
'id': 'grp001',
'filename': 'a.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
cap_b = {
'id': 'grp002',
'filename': 'b.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:01:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result['status'] == 'error'
def test_start_sweep_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stdout = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'):
manager._sweep_available = None
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
assert result['status'] == 'started'
# Signal daemon threads to stop so they don't outlive the test
manager._sweep_running = False
def test_stop_sweep_not_running(self, manager):
result = manager.stop_sweep()
assert result['status'] == 'not_running'
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name):
if name == 'hackrf_transfer':
return '/usr/bin/hackrf_transfer'
return None
with patch('shutil.which', side_effect=which_side_effect):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'rtl_433' in result['message']
def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock()
mock_hackrf_proc.poll.return_value = None
mock_hackrf_proc.stdout = MagicMock()
mock_hackrf_proc.stderr = MagicMock()
mock_hackrf_proc.stderr.readline = MagicMock(return_value=b'')
mock_rtl433_proc = MagicMock()
mock_rtl433_proc.poll.return_value = None
mock_rtl433_proc.stdout = MagicMock()
mock_rtl433_proc.stderr = MagicMock()
mock_rtl433_proc.stderr.readline = MagicMock(return_value=b'')
call_count = [0]
def popen_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return mock_hackrf_proc
return mock_rtl433_proc
with patch('shutil.which', return_value='/usr/bin/tool'), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(
frequency_hz=433920000,
sample_rate=2000000,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'decode'
# Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert hackrf_cmd[0] == 'hackrf_transfer'
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc
assert manager._decode_process is mock_rtl433_proc
# Signal daemon threads to stop so they don't outlive the test
manager._decode_stop = True
def test_stop_decode_not_running(self, manager):
result = manager.stop_decode()
assert result['status'] == 'not_running'
def test_stop_decode_terminates_both(self, manager):
mock_hackrf = MagicMock()
mock_hackrf.poll.return_value = None
mock_rtl433 = MagicMock()
mock_rtl433.poll.return_value = None
manager._decode_hackrf_process = mock_hackrf
manager._decode_process = mock_rtl433
manager._decode_frequency_hz = 433920000
with patch('utils.subghz.safe_terminate') as mock_term, \
patch('utils.subghz.unregister_process'):
result = manager.stop_decode()
assert result['status'] == 'stopped'
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert mock_term.call_count == 2
class TestStopAll:
def test_stop_all_clears_processes(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
with patch('utils.subghz.safe_terminate'):
manager.stop_all()
assert manager._rx_process is None
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert manager._tx_process is None
assert manager._sweep_process is None
class TestSubGhzCapture:
def test_to_dict(self):
cap = SubGhzCapture(
capture_id='abc123',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
duration_seconds=5.0,
size_bytes=1024,
label='Test',
)
d = cap.to_dict()
assert d['id'] == 'abc123'
assert d['frequency_hz'] == 433920000
assert d['label'] == 'Test'
+433
View File
@@ -0,0 +1,433 @@
"""Tests for SubGHz transceiver routes."""
from __future__ import annotations
import json
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzCapture
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestSubGhzRoutes:
"""Tests for /subghz/ endpoints."""
def test_get_status(self, client, auth_client):
"""GET /subghz/status returns manager status."""
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_status.return_value = {
'mode': 'idle',
'hackrf_available': True,
'rtl433_available': True,
'sweep_available': True,
}
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/status')
assert response.status_code == 200
data = response.get_json()
assert data['mode'] == 'idle'
assert data['hackrf_available'] is True
def test_get_presets(self, client, auth_client):
"""GET /subghz/presets returns frequency presets."""
response = auth_client.get('/subghz/presets')
assert response.status_code == 200
data = response.get_json()
assert 'presets' in data
assert '433.92 MHz' in data['presets']
assert 'sample_rates' in data
# ------ RECEIVE ------
def test_start_receive_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {
'status': 'started',
'frequency_hz': 433920000,
'sample_rate': 2000000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_receive_missing_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={})
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_receive_invalid_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 'not_a_number',
})
assert response.status_code == 400
def test_stop_receive(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_receive.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/stop')
assert response.status_code == 200
def test_start_receive_trigger_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {'status': 'started'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'trigger_enabled': True,
'trigger_pre_ms': 400,
'trigger_post_ms': 900,
})
assert response.status_code == 200
kwargs = mock_mgr.start_receive.call_args.kwargs
assert kwargs['trigger_enabled'] is True
assert kwargs['trigger_pre_ms'] == 400
assert kwargs['trigger_post_ms'] == 900
# ------ DECODE ------
def test_start_decode_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
mock_mgr.start_decode.assert_called_once()
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'weather'
def test_start_decode_profile_all(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
'decode_profile': 'all',
})
assert response.status_code == 200
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'all'
def test_start_decode_missing_freq(self, client, auth_client):
response = auth_client.post('/subghz/decode/start', json={})
assert response.status_code == 400
def test_stop_decode(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_decode.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/stop')
assert response.status_code == 200
# ------ TRANSMIT ------
def test_transmit_missing_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={})
assert response.status_code == 400
data = response.get_json()
assert 'capture_id is required' in data['message']
def test_transmit_invalid_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': '../../../etc/passwd',
})
assert response.status_code == 400
data = response.get_json()
assert 'Invalid' in data['message']
def test_transmit_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'transmitting'
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] is None
assert kwargs['duration_seconds'] is None
def test_transmit_segment_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
'segment': {'start_seconds': 0.1, 'duration_seconds': 0.4},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
'start_seconds': 0.1,
'duration_seconds': 0.4,
})
assert response.status_code == 200
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.4
def test_transmit_invalid_segment_param(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'start_seconds': 'not-a-number',
})
assert response.status_code == 400
def test_stop_transmit(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_transmit.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit/stop')
assert response.status_code == 200
# ------ SWEEP ------
def test_start_sweep_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_sweep.return_value = {
'status': 'started',
'freq_start_mhz': 300,
'freq_end_mhz': 928,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 300,
'freq_end_mhz': 928,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_sweep_invalid_range(self, client, auth_client):
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 928,
'freq_end_mhz': 300, # start > end
})
assert response.status_code == 400
def test_stop_sweep(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_sweep.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/stop')
assert response.status_code == 200
# ------ CAPTURES ------
def test_list_captures_empty(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.list_captures.return_value = []
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 0
assert data['captures'] == []
def test_list_captures_with_data(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap1',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.list_captures.return_value = [cap]
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 1
assert data['captures'][0]['id'] == 'cap1'
def test_get_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap2',
filename='test2.iq',
frequency_hz=315000000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.get_capture.return_value = cap
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/cap2')
assert response.status_code == 200
data = response.get_json()
assert data['capture']['frequency_hz'] == 315000000
def test_get_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_capture.return_value = None
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_get_capture_invalid_id(self, client, auth_client):
response = auth_client.get('/subghz/captures/bad-id!')
assert response.status_code == 400
def test_delete_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/cap1')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'deleted'
def test_trim_capture_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.trim_capture.return_value = {
'status': 'ok',
'capture': {
'id': 'trim_new',
'filename': 'trimmed.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 0.1,
'duration_seconds': 0.3,
})
assert response.status_code == 200
kwargs = mock_mgr.trim_capture.call_args.kwargs
assert kwargs['capture_id'] == 'cap1'
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.3
def test_trim_capture_invalid_param(self, client, auth_client):
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 'bad',
})
assert response.status_code == 400
def test_delete_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_update_capture_label(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'Garage Remote',
})
assert response.status_code == 200
data = response.get_json()
assert data['label'] == 'Garage Remote'
def test_update_capture_label_too_long(self, client, auth_client):
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'x' * 200,
})
assert response.status_code == 400
def test_update_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/nonexistent', json={
'label': 'test',
})
assert response.status_code == 404
# ------ SSE STREAM ------
def test_stream_endpoint(self, client, auth_client):
"""GET /subghz/stream returns SSE response."""
with patch('routes.subghz.sse_stream', return_value=iter([])):
response = auth_client.get('/subghz/stream')
assert response.status_code == 200
assert response.content_type.startswith('text/event-stream')
+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
+37 -8
View File
@@ -66,7 +66,7 @@ class BluetoothScanner:
self._scan_timer: Optional[threading.Timer] = None
# Callbacks
self._on_device_updated: Optional[Callable[[BTDeviceAggregate], None]] = None
self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = []
# Capability check result
self._capabilities: Optional[SystemCapabilities] = None
@@ -236,9 +236,12 @@ class BluetoothScanner:
'device': device.to_summary_dict(),
})
# Callback
if self._on_device_updated:
self._on_device_updated(device)
# Callbacks
for cb in self._on_device_updated_callbacks:
try:
cb(device)
except Exception as cb_err:
logger.error(f"Device callback error: {cb_err}")
except Exception as e:
logger.error(f"Error handling observation: {e}")
@@ -368,13 +371,39 @@ class BluetoothScanner:
return self._capabilities
def set_on_device_updated(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
"""Set callback for device updates."""
self._on_device_updated = callback
"""Set callback for device updates (legacy, adds to callback list)."""
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
def is_scanning(self) -> bool:
"""Check if scanning is active."""
return self._status.is_scanning
"""Check if scanning is active.
Cross-checks the backend scanner state, since bleak scans can
expire silently without calling stop_scan().
"""
if not self._status.is_scanning:
return False
# Detect backends that finished on their own (e.g. bleak timeout)
backend_alive = (
(self._dbus_scanner and self._dbus_scanner.is_scanning)
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
)
if not backend_alive:
self._status.is_scanning = False
return False
return True
@property
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
+44
View File
@@ -256,6 +256,50 @@ MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
DSC_TERMINATE_TIMEOUT = 3
# =============================================================================
# SUBGHZ TRANSCEIVER (HackRF)
# =============================================================================
# Allowed ISM TX frequency bands (MHz) - transmit only within these ranges
SUBGHZ_TX_ALLOWED_BANDS = [
(300.0, 348.0), # 315 MHz ISM band
(387.0, 464.0), # 433 MHz ISM band
(779.0, 928.0), # 868/915 MHz ISM band
]
# HackRF frequency limits (MHz)
SUBGHZ_FREQ_MIN_MHZ = 1.0
SUBGHZ_FREQ_MAX_MHZ = 6000.0
# HackRF gain ranges
SUBGHZ_LNA_GAIN_MIN = 0
SUBGHZ_LNA_GAIN_MAX = 40
SUBGHZ_VGA_GAIN_MIN = 0
SUBGHZ_VGA_GAIN_MAX = 62
SUBGHZ_TX_VGA_GAIN_MIN = 0
SUBGHZ_TX_VGA_GAIN_MAX = 47
# Default sample rates available (Hz)
SUBGHZ_SAMPLE_RATES = [2000000, 4000000, 8000000, 10000000, 20000000]
# Maximum TX duration watchdog (seconds)
SUBGHZ_TX_MAX_DURATION = 30
# Sweep defaults
SUBGHZ_SWEEP_BIN_WIDTH = 100000 # 100 kHz bins
# SubGHz process termination timeout
SUBGHZ_TERMINATE_TIMEOUT = 3
# Common SubGHz preset frequencies (MHz)
SUBGHZ_PRESETS = {
'315 MHz': 315.0,
'433.92 MHz': 433.92,
'868 MHz': 868.0,
'915 MHz': 915.0,
}
# =============================================================================
# DEAUTH ATTACK DETECTION
# =============================================================================
+132
View File
@@ -531,6 +531,30 @@ def init_db() -> None:
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")
@@ -2170,3 +2194,111 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
''', (f'-{max_age_hours} hours',))
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'
+32
View File
@@ -394,6 +394,38 @@ TOOL_DEPENDENCIES = {
}
}
},
'subghz': {
'name': 'SubGHz Transceiver',
'tools': {
'hackrf_transfer': {
'required': True,
'description': 'HackRF IQ capture and replay',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'hackrf_sweep': {
'required': False,
'description': 'HackRF wideband spectrum sweep',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'rtl_433': {
'required': False,
'description': 'Protocol decoder for SubGHz signals',
'install': {
'apt': 'sudo apt install rtl-433',
'brew': 'brew install rtl_433',
'manual': 'https://github.com/merbanan/rtl_433'
}
}
}
},
'tscm': {
'name': 'TSCM Counter-Surveillance',
'tools': {
+17 -2
View File
@@ -264,6 +264,20 @@ class DSCDecoder:
symbol_value = self._bits_to_symbol(symbol_bits)
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
eos_found = False
eos_index = -1
@@ -404,9 +418,10 @@ class DSCDecoder:
digits.append(f'{sym:02d}')
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:
mmsi = mmsi[-9:]
mmsi = mmsi[1:]
return mmsi.zfill(9)
+183 -30
View File
@@ -6,28 +6,86 @@ Provides GPS location data by connecting to the gpsd daemon.
from __future__ import annotations
import contextlib
import logging
import socket as _socket_mod
import threading
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Callable
from typing import Callable
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
class GPSPosition:
"""GPS position data."""
latitude: float
longitude: float
altitude: Optional[float] = None
speed: Optional[float] = None # m/s
heading: Optional[float] = None # degrees
satellites: Optional[int] = None
altitude: float | None = None
speed: float | None = None # m/s
heading: float | None = None # degrees
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
timestamp: Optional[datetime] = None
device: Optional[str] = None
timestamp: datetime | None = 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:
"""Convert to dictionary for JSON serialization."""
@@ -37,13 +95,45 @@ class GPSPosition:
'altitude': self.altitude,
'speed': self.speed,
'heading': self.heading,
'climb': self.climb,
'satellites': self.satellites,
'fix_quality': self.fix_quality,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'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:
"""
Connects to gpsd daemon for GPS data.
@@ -58,35 +148,43 @@ class GPSDClient:
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
self.host = host
self.port = port
self._position: Optional[GPSPosition] = None
self._position: GPSPosition | None = None
self._sky: GPSSkyData | None = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._socket: Optional['socket.socket'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._thread: threading.Thread | None = None
self._socket: _socket_mod.socket | None = None
self._last_update: datetime | None = None
self._error: str | None = 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
def position(self) -> Optional[GPSPosition]:
def position(self) -> GPSPosition | None:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def sky(self) -> GPSSkyData | None:
"""Get the current sky view data."""
with self._lock:
return self._sky
@property
def is_running(self) -> bool:
"""Check if the client is running."""
return self._running
@property
def last_update(self) -> Optional[datetime]:
def last_update(self) -> datetime | None:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> Optional[str]:
def error(self) -> str | None:
"""Get any error message."""
with self._lock:
return self._error
@@ -105,6 +203,15 @@ class GPSDClient:
if callback in self._callbacks:
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:
"""Start receiving GPS data from gpsd."""
import socket
@@ -135,10 +242,8 @@ class GPSDClient:
self._error = str(e)
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
if self._socket:
try:
with contextlib.suppress(Exception):
self._socket.close()
except Exception:
pass
self._socket = None
return False
@@ -169,7 +274,7 @@ class GPSDClient:
buffer = ""
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:
try:
@@ -202,6 +307,8 @@ class GPSDClient:
if msg_class == 'TPV':
self._handle_tpv(msg)
elif msg_class == 'SKY':
self._handle_sky(msg)
elif msg_class == 'DEVICES':
# Track connected device
devices = msg.get('devices', [])
@@ -239,11 +346,9 @@ class GPSDClient:
timestamp = None
time_str = msg.get('time')
if time_str:
try:
with contextlib.suppress(ValueError, AttributeError):
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
pass
position = GPSPosition(
latitude=lat,
@@ -251,14 +356,58 @@ class GPSDClient:
altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd
heading=msg.get('track'),
climb=msg.get('climb'),
fix_quality=mode,
timestamp=timestamp,
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)
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:
"""Update the current position and notify callbacks."""
with self._lock:
@@ -275,18 +424,19 @@ class GPSDClient:
# Global GPS client instance
_gps_client: Optional[GPSDClient] = None
_gps_client: GPSDClient | None = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSDClient]:
def get_gps_reader() -> GPSDClient | None:
"""Get the global GPS client instance."""
with _gps_lock:
return _gps_client
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.
@@ -294,6 +444,7 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
host: gpsd host (default localhost)
port: gpsd port (default 2947)
callback: Optional callback for position updates
sky_callback: Optional callback for sky data updates
Returns:
True if started successfully
@@ -307,9 +458,11 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
_gps_client = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition
# Register callbacks BEFORE starting to avoid race condition
if callback:
_gps_client.add_callback(callback)
if sky_callback:
_gps_client.add_sky_callback(sky_callback)
return _gps_client.start()
@@ -324,7 +477,7 @@ def stop_gps() -> 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."""
client = get_gps_reader()
if client:
+51 -20
View File
@@ -6,15 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations
import logging
import re
import shutil
import subprocess
from typing import Optional
import logging
import re
import shutil
import subprocess
import time
from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception:
return False
def _check_tool(name: str) -> bool:
@@ -295,16 +311,29 @@ def _add_soapy_device(
))
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
return devices
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
global _hackrf_cache, _hackrf_cache_ts
now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked():
return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache)
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
try:
result = subprocess.run(
@@ -342,10 +371,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
capabilities=HackRFCommandBuilder.CAPABILITIES
))
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
return devices
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices)
_hackrf_cache_ts = now
return devices
def probe_rtlsdr_device(device_index: int) -> str | None:
+19 -12
View File
@@ -14,10 +14,16 @@ from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map app/UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
@@ -87,14 +93,15 @@ class RTLSDRCommandBuilder(CommandBuilder):
Used for pager decoding. Supports local devices and rtl_tcp connections.
"""
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
cmd = [
rtl_fm_path,
'-d', self._get_device_arg(device),
'-f', f'{frequency_mhz}M',
'-M', modulation,
'-s', str(sample_rate),
]
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
demod_mode = _rtl_fm_demod_mode(modulation)
cmd = [
rtl_fm_path,
'-d', self._get_device_arg(device),
'-f', f'{frequency_mhz}M',
'-M', demod_mode,
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(gain)])
+4
View File
@@ -311,6 +311,10 @@ class VISDetector:
if len(self._data_bits) != 8:
return None
# VIS uses even parity across 8 data bits + parity bit.
if (sum(self._data_bits) + self._parity_bit) % 2 != 0:
return None
# Decode VIS code (LSB first)
vis_code = 0
for i, bit in enumerate(self._data_bits):
+2809
View File
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -3,10 +3,11 @@
Provides automated capture and decoding of weather satellite images using SatDump.
Supported satellites:
- NOAA-15: 137.620 MHz (APT)
- NOAA-18: 137.9125 MHz (APT)
- NOAA-19: 137.100 MHz (APT)
- NOAA-15: 137.620 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- NOAA-18: 137.9125 MHz (APT) [DEFUNCT - decommissioned Jun 2025]
- NOAA-19: 137.100 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- Meteor-M2-3: 137.900 MHz (LRPT)
- Meteor-M2-4: 137.900 MHz (LRPT)
Uses SatDump CLI for live SDR capture and decoding, with fallback to
rtl_fm capture for manual decoding when SatDump is unavailable.
@@ -42,8 +43,8 @@ WEATHER_SATELLITES = {
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-15',
'description': 'NOAA-15 APT (analog weather imagery)',
'active': True,
'description': 'NOAA-15 APT (decommissioned Aug 2025)',
'active': False,
},
'NOAA-18': {
'name': 'NOAA 18',
@@ -51,8 +52,8 @@ WEATHER_SATELLITES = {
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-18',
'description': 'NOAA-18 APT (analog weather imagery)',
'active': True,
'description': 'NOAA-18 APT (decommissioned Jun 2025)',
'active': False,
},
'NOAA-19': {
'name': 'NOAA 19',
@@ -60,8 +61,8 @@ WEATHER_SATELLITES = {
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-19',
'description': 'NOAA-19 APT (analog weather imagery)',
'active': True,
'description': 'NOAA-19 APT (decommissioned Aug 2025)',
'active': False,
},
'METEOR-M2-3': {
'name': 'Meteor-M2-3',
@@ -72,6 +73,15 @@ WEATHER_SATELLITES = {
'description': 'Meteor-M2-3 LRPT (digital color imagery)',
'active': True,
},
'METEOR-M2-4': {
'name': 'Meteor-M2-4',
'frequency': 137.900,
'mode': 'LRPT',
'pipeline': 'meteor_m2-x_lrpt',
'tle_key': 'METEOR-M2-4',
'description': 'Meteor-M2-4 LRPT (digital color imagery)',
'active': True,
},
}
# Default sample rate for weather satellite reception