mirror of
https://github.com/smittix/intercept.git
synced 2026-06-12 07:53:30 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d08a8b1e | |||
| c60769f795 | |||
| 01f8324292 | |||
| c66988cc1c | |||
| fac3d4359b | |||
| d6f10d29ca | |||
| 332735cecf | |||
| b04e335f49 | |||
| 75e50a1cd4 | |||
| 243a0f0e7f | |||
| 7c3ec9e920 | |||
| 4639146f05 | |||
| a354fee792 |
@@ -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.*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -340,7 +340,9 @@
|
||||
MODE VISIBILITY - Ensure sidebar shows when active
|
||||
============================================ */
|
||||
#spystationsMode.active {
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
MODE VISIBILITY
|
||||
============================================ */
|
||||
#sstvGeneralMode.active {
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* BT Locate — Bluetooth SAR Device Location Mode
|
||||
* GPS-tagged signal trail mapping with proximity audio alerts.
|
||||
*/
|
||||
const BtLocate = (function() {
|
||||
'use strict';
|
||||
|
||||
let eventSource = null;
|
||||
let map = null;
|
||||
let mapMarkers = [];
|
||||
let trailLine = null;
|
||||
let rssiHistory = [];
|
||||
const MAX_RSSI_POINTS = 60;
|
||||
let chartCanvas = null;
|
||||
let chartCtx = null;
|
||||
let currentEnvironment = 'OUTDOOR';
|
||||
let audioCtx = null;
|
||||
let audioEnabled = false;
|
||||
let beepTimer = null;
|
||||
let initialized = false;
|
||||
let handoffData = null;
|
||||
let pollTimer = null;
|
||||
let durationTimer = null;
|
||||
let sessionStartedAt = null;
|
||||
let lastDetectionCount = 0;
|
||||
|
||||
function init() {
|
||||
if (initialized) {
|
||||
// Re-invalidate map on re-entry and ensure tiles are present
|
||||
if (map) {
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
// Re-apply user's tile layer if tiles were lost
|
||||
let hasTiles = false;
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.TileLayer) hasTiles = true;
|
||||
});
|
||||
if (!hasTiles && typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
Settings.createTileLayer().addTo(map);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
checkStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Init map
|
||||
const mapEl = document.getElementById('btLocateMap');
|
||||
if (mapEl && typeof L !== 'undefined') {
|
||||
map = L.map('btLocateMap', {
|
||||
center: [0, 0],
|
||||
zoom: 2,
|
||||
zoomControl: true,
|
||||
});
|
||||
// Use tile provider from user settings
|
||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
Settings.createTileLayer().addTo(map);
|
||||
Settings.registerMap(map);
|
||||
} else {
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OSM © CARTO'
|
||||
}).addTo(map);
|
||||
}
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
}
|
||||
|
||||
// Init RSSI chart canvas
|
||||
chartCanvas = document.getElementById('btLocateRssiChart');
|
||||
if (chartCanvas) {
|
||||
chartCtx = chartCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
checkStatus();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.active) {
|
||||
sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
updateScanStatus(data);
|
||||
if (!eventSource) connectSSE();
|
||||
// Restore trail from server
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.gps_trail) {
|
||||
trail.gps_trail.forEach(p => addMapMarker(p));
|
||||
}
|
||||
updateStats(data.detection_count, data.gps_trail_count);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function start() {
|
||||
const mac = document.getElementById('btLocateMac')?.value.trim();
|
||||
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
|
||||
const irk = document.getElementById('btLocateIrk')?.value.trim();
|
||||
|
||||
const body = { environment: currentEnvironment };
|
||||
if (mac) body.mac_address = mac;
|
||||
if (namePattern) body.name_pattern = namePattern;
|
||||
if (irk) body.irk_hex = irk;
|
||||
if (handoffData?.device_id) body.device_id = handoffData.device_id;
|
||||
if (handoffData?.known_name) body.known_name = handoffData.known_name;
|
||||
if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer;
|
||||
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
|
||||
|
||||
// Include user location as fallback when GPS unavailable
|
||||
const userLat = localStorage.getItem('observerLat');
|
||||
const userLon = localStorage.getItem('observerLon');
|
||||
if (userLat && userLon) {
|
||||
body.fallback_lat = parseFloat(userLat);
|
||||
body.fallback_lon = parseFloat(userLon);
|
||||
}
|
||||
|
||||
console.log('[BtLocate] Starting with body:', body);
|
||||
|
||||
if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id) {
|
||||
alert('Please provide at least a MAC address, name pattern, IRK, or use hand-off from Bluetooth mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/bt_locate/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
connectSSE();
|
||||
rssiHistory = [];
|
||||
updateScanStatus(data.session);
|
||||
// Restore any existing trail (e.g. from a stop/start cycle)
|
||||
restoreTrail();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Start error:', err));
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/bt_locate/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
stopAudio();
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||
}
|
||||
|
||||
function showActiveUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = 'inline-block';
|
||||
show('btLocateHud');
|
||||
}
|
||||
|
||||
function showIdleUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'inline-block';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
hide('btLocateHud');
|
||||
hide('btLocateScanStatus');
|
||||
}
|
||||
|
||||
function updateScanStatus(statusData) {
|
||||
const el = document.getElementById('btLocateScanStatus');
|
||||
const dot = document.getElementById('btLocateScanDot');
|
||||
const text = document.getElementById('btLocateScanText');
|
||||
if (!el) return;
|
||||
|
||||
el.style.display = '';
|
||||
if (statusData && statusData.scanner_running) {
|
||||
if (dot) dot.style.background = '#22c55e';
|
||||
if (text) text.textContent = 'BT scanner active';
|
||||
} else {
|
||||
if (dot) dot.style.background = '#f97316';
|
||||
if (text) text.textContent = 'BT scanner not running — waiting...';
|
||||
}
|
||||
}
|
||||
|
||||
function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
|
||||
function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
console.log('[BtLocate] Connecting SSE stream');
|
||||
eventSource = new EventSource('/bt_locate/stream');
|
||||
|
||||
eventSource.addEventListener('detection', function(e) {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
console.log('[BtLocate] Detection event:', event);
|
||||
handleDetection(event);
|
||||
} catch (err) {
|
||||
console.error('[BtLocate] Parse error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('session_ended', function() {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
});
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[BtLocate] SSE error, polling fallback active');
|
||||
};
|
||||
|
||||
// Start polling fallback (catches data even if SSE fails)
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
lastDetectionCount = 0;
|
||||
pollTimer = setInterval(pollStatus, 3000);
|
||||
startDurationTimer();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
stopDurationTimer();
|
||||
}
|
||||
|
||||
function startDurationTimer() {
|
||||
stopDurationTimer();
|
||||
durationTimer = setInterval(updateDuration, 1000);
|
||||
}
|
||||
|
||||
function stopDurationTimer() {
|
||||
if (durationTimer) {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDuration() {
|
||||
if (!sessionStartedAt) return;
|
||||
const elapsed = Math.round((Date.now() - sessionStartedAt) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeEl = document.getElementById('btLocateSessionTime');
|
||||
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.active) {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
|
||||
updateScanStatus(data);
|
||||
updateHudInfo(data);
|
||||
|
||||
// Show diagnostics
|
||||
const diagEl = document.getElementById('btLocateDiag');
|
||||
if (diagEl) {
|
||||
let diag = 'Polls: ' + (data.poll_count || 0) +
|
||||
(data.poll_thread_alive === false ? ' DEAD' : '') +
|
||||
' | Scan: ' + (data.scanner_running ? 'Y' : 'N') +
|
||||
' | Devices: ' + (data.scanner_device_count || 0) +
|
||||
' | Det: ' + (data.detection_count || 0);
|
||||
// Show debug device sample if no detections
|
||||
if (data.detection_count === 0 && data.debug_devices && data.debug_devices.length > 0) {
|
||||
const matched = data.debug_devices.filter(d => d.match);
|
||||
const sample = data.debug_devices.slice(0, 3).map(d =>
|
||||
(d.name || '?') + '|' + (d.id || '').substring(0, 12) + ':' + (d.match ? 'Y' : 'N')
|
||||
).join(', ');
|
||||
diag += ' | Match:' + matched.length + '/' + data.debug_devices.length + ' [' + sample + ']';
|
||||
}
|
||||
diagEl.textContent = diag;
|
||||
}
|
||||
|
||||
// If detection count increased, fetch new trail points
|
||||
if (data.detection_count > lastDetectionCount) {
|
||||
lastDetectionCount = data.detection_count;
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.trail && trail.trail.length > 0) {
|
||||
const latest = trail.trail[trail.trail.length - 1];
|
||||
handleDetection({ data: latest });
|
||||
}
|
||||
updateStats(data.detection_count, data.gps_trail_count);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateHudInfo(data) {
|
||||
// Target info
|
||||
const targetEl = document.getElementById('btLocateTargetInfo');
|
||||
if (targetEl && data.target) {
|
||||
const t = data.target;
|
||||
const name = t.known_name || t.name_pattern || '';
|
||||
const addr = t.mac_address || t.device_id || '';
|
||||
targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--';
|
||||
}
|
||||
|
||||
// Environment info
|
||||
const envEl = document.getElementById('btLocateEnvInfo');
|
||||
if (envEl) {
|
||||
const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' };
|
||||
envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?');
|
||||
}
|
||||
|
||||
// GPS status
|
||||
const gpsEl = document.getElementById('btLocateGpsStatus');
|
||||
if (gpsEl) {
|
||||
const src = data.gps_source || 'none';
|
||||
if (src === 'live') gpsEl.textContent = 'GPS: Live';
|
||||
else if (src === 'manual') gpsEl.textContent = 'GPS: Manual';
|
||||
else gpsEl.textContent = 'GPS: None';
|
||||
}
|
||||
|
||||
// Last seen
|
||||
const lastEl = document.getElementById('btLocateLastSeen');
|
||||
if (lastEl) {
|
||||
if (data.last_detection) {
|
||||
const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000);
|
||||
lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago');
|
||||
} else {
|
||||
lastEl.textContent = 'Last: --';
|
||||
}
|
||||
}
|
||||
|
||||
// Session start time (duration handled by 1s timer)
|
||||
if (data.started_at && !sessionStartedAt) {
|
||||
sessionStartedAt = new Date(data.started_at).getTime();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetection(event) {
|
||||
const d = event.data;
|
||||
if (!d) return;
|
||||
|
||||
// Update proximity UI
|
||||
const bandEl = document.getElementById('btLocateBand');
|
||||
const distEl = document.getElementById('btLocateDistance');
|
||||
const rssiEl = document.getElementById('btLocateRssi');
|
||||
const rssiEmaEl = document.getElementById('btLocateRssiEma');
|
||||
|
||||
if (bandEl) {
|
||||
bandEl.textContent = d.proximity_band;
|
||||
bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase();
|
||||
}
|
||||
if (distEl) distEl.textContent = d.estimated_distance.toFixed(1);
|
||||
if (rssiEl) rssiEl.textContent = d.rssi;
|
||||
if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
|
||||
|
||||
// RSSI sparkline
|
||||
rssiHistory.push(d.rssi);
|
||||
if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
|
||||
drawRssiChart();
|
||||
|
||||
// Map marker
|
||||
if (d.lat != null && d.lon != null) {
|
||||
addMapMarker(d);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||
if (detCountEl) {
|
||||
const cur = parseInt(detCountEl.textContent) || 0;
|
||||
detCountEl.textContent = cur + 1;
|
||||
}
|
||||
if (gpsCountEl && d.lat != null) {
|
||||
const cur = parseInt(gpsCountEl.textContent) || 0;
|
||||
gpsCountEl.textContent = cur + 1;
|
||||
}
|
||||
|
||||
// Audio
|
||||
if (audioEnabled) playProximityTone(d.rssi);
|
||||
}
|
||||
|
||||
function updateStats(detections, gpsPoints) {
|
||||
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||
if (detCountEl) detCountEl.textContent = detections || 0;
|
||||
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
||||
}
|
||||
|
||||
function addMapMarker(point) {
|
||||
if (!map || point.lat == null || point.lon == null) return;
|
||||
|
||||
const band = (point.proximity_band || 'FAR').toLowerCase();
|
||||
const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
|
||||
const sizes = { immediate: 8, near: 6, far: 5 };
|
||||
const color = colors[band] || '#eab308';
|
||||
const radius = sizes[band] || 5;
|
||||
|
||||
const marker = L.circleMarker([point.lat, point.lon], {
|
||||
radius: radius,
|
||||
fillColor: color,
|
||||
color: '#fff',
|
||||
weight: 1,
|
||||
opacity: 0.9,
|
||||
fillOpacity: 0.8,
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(
|
||||
'<div style="font-family:monospace;font-size:11px;">' +
|
||||
'<b>' + point.proximity_band + '</b><br>' +
|
||||
'RSSI: ' + point.rssi + ' dBm<br>' +
|
||||
'Distance: ~' + point.estimated_distance.toFixed(1) + ' m<br>' +
|
||||
'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
mapMarkers.push(marker);
|
||||
map.panTo([point.lat, point.lon]);
|
||||
|
||||
// Update trail line
|
||||
const latlngs = mapMarkers.map(m => m.getLatLng());
|
||||
if (trailLine) {
|
||||
trailLine.setLatLngs(latlngs);
|
||||
} else if (latlngs.length >= 2) {
|
||||
trailLine = L.polyline(latlngs, {
|
||||
color: 'rgba(0,255,136,0.5)',
|
||||
weight: 2,
|
||||
dashArray: '4 4',
|
||||
}).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTrail() {
|
||||
fetch('/bt_locate/trail')
|
||||
.then(r => r.json())
|
||||
.then(trail => {
|
||||
if (trail.gps_trail && trail.gps_trail.length > 0) {
|
||||
clearMapMarkers();
|
||||
trail.gps_trail.forEach(p => addMapMarker(p));
|
||||
}
|
||||
if (trail.trail && trail.trail.length > 0) {
|
||||
// Restore RSSI history from trail
|
||||
rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
|
||||
drawRssiChart();
|
||||
// Update HUD with latest detection
|
||||
const latest = trail.trail[trail.trail.length - 1];
|
||||
handleDetection({ data: latest });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function clearMapMarkers() {
|
||||
mapMarkers.forEach(m => map?.removeLayer(m));
|
||||
mapMarkers = [];
|
||||
if (trailLine) {
|
||||
map?.removeLayer(trailLine);
|
||||
trailLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRssiChart() {
|
||||
if (!chartCtx || !chartCanvas) return;
|
||||
|
||||
const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16;
|
||||
const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24;
|
||||
chartCtx.clearRect(0, 0, w, h);
|
||||
|
||||
if (rssiHistory.length < 2) return;
|
||||
|
||||
// RSSI range: -100 to -20
|
||||
const minR = -100, maxR = -20;
|
||||
const range = maxR - minR;
|
||||
|
||||
// Grid lines
|
||||
chartCtx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
chartCtx.lineWidth = 1;
|
||||
[-30, -50, -70, -90].forEach(v => {
|
||||
const y = h - ((v - minR) / range) * h;
|
||||
chartCtx.beginPath();
|
||||
chartCtx.moveTo(0, y);
|
||||
chartCtx.lineTo(w, y);
|
||||
chartCtx.stroke();
|
||||
});
|
||||
|
||||
// Draw RSSI line
|
||||
const step = w / (MAX_RSSI_POINTS - 1);
|
||||
chartCtx.beginPath();
|
||||
chartCtx.strokeStyle = '#00ff88';
|
||||
chartCtx.lineWidth = 2;
|
||||
|
||||
rssiHistory.forEach((rssi, i) => {
|
||||
const x = i * step;
|
||||
const y = h - ((rssi - minR) / range) * h;
|
||||
if (i === 0) chartCtx.moveTo(x, y);
|
||||
else chartCtx.lineTo(x, y);
|
||||
});
|
||||
chartCtx.stroke();
|
||||
|
||||
// Fill under
|
||||
const lastIdx = rssiHistory.length - 1;
|
||||
chartCtx.lineTo(lastIdx * step, h);
|
||||
chartCtx.lineTo(0, h);
|
||||
chartCtx.closePath();
|
||||
chartCtx.fillStyle = 'rgba(0,255,136,0.08)';
|
||||
chartCtx.fill();
|
||||
}
|
||||
|
||||
// Audio proximity tone (Web Audio API)
|
||||
function playTone(freq, duration) {
|
||||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.2;
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + duration);
|
||||
}
|
||||
|
||||
function playProximityTone(rssi) {
|
||||
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||
// Stronger signal = higher pitch and shorter beep
|
||||
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
|
||||
const freq = 400 + strength * 800; // 400-1200 Hz
|
||||
const duration = 0.06 + (1 - strength) * 0.12;
|
||||
playTone(freq, duration);
|
||||
}
|
||||
|
||||
function toggleAudio() {
|
||||
const cb = document.getElementById('btLocateAudioEnable');
|
||||
audioEnabled = cb?.checked || false;
|
||||
if (audioEnabled) {
|
||||
// Create AudioContext on user gesture (required by browser policy)
|
||||
if (!audioCtx) {
|
||||
try {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} catch (e) {
|
||||
console.error('[BtLocate] AudioContext creation failed:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Resume must happen within a user gesture handler
|
||||
const ctx = audioCtx;
|
||||
ctx.resume().then(() => {
|
||||
console.log('[BtLocate] AudioContext state:', ctx.state);
|
||||
// Confirmation beep so user knows audio is working
|
||||
playTone(600, 0.08);
|
||||
});
|
||||
} else {
|
||||
stopAudio();
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
audioEnabled = false;
|
||||
const cb = document.getElementById('btLocateAudioEnable');
|
||||
if (cb) cb.checked = false;
|
||||
}
|
||||
|
||||
function setEnvironment(env) {
|
||||
currentEnvironment = env;
|
||||
document.querySelectorAll('.btl-env-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.env === env);
|
||||
});
|
||||
// Push to running session if active
|
||||
fetch('/bt_locate/status').then(r => r.json()).then(data => {
|
||||
if (data.active) {
|
||||
fetch('/bt_locate/environment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ environment: env }),
|
||||
}).then(r => r.json()).then(res => {
|
||||
console.log('[BtLocate] Environment updated:', res);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function handoff(deviceInfo) {
|
||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||
handoffData = deviceInfo;
|
||||
|
||||
// Populate fields
|
||||
if (deviceInfo.mac_address) {
|
||||
const macInput = document.getElementById('btLocateMac');
|
||||
if (macInput) macInput.value = deviceInfo.mac_address;
|
||||
}
|
||||
|
||||
// Show handoff card
|
||||
const card = document.getElementById('btLocateHandoffCard');
|
||||
const nameEl = document.getElementById('btLocateHandoffName');
|
||||
const metaEl = document.getElementById('btLocateHandoffMeta');
|
||||
if (card) card.style.display = '';
|
||||
if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown';
|
||||
if (metaEl) {
|
||||
const parts = [];
|
||||
if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address);
|
||||
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
|
||||
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
|
||||
metaEl.textContent = parts.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate');
|
||||
}
|
||||
}
|
||||
|
||||
function clearHandoff() {
|
||||
handoffData = null;
|
||||
const card = document.getElementById('btLocateHandoffCard');
|
||||
if (card) card.style.display = 'none';
|
||||
}
|
||||
|
||||
function fetchPairedIrks() {
|
||||
const picker = document.getElementById('btLocateIrkPicker');
|
||||
const status = document.getElementById('btLocateIrkPickerStatus');
|
||||
const list = document.getElementById('btLocateIrkPickerList');
|
||||
const btn = document.getElementById('btLocateDetectIrkBtn');
|
||||
if (!picker || !status || !list) return;
|
||||
|
||||
// Toggle off if already visible
|
||||
if (picker.style.display !== 'none') {
|
||||
picker.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
picker.style.display = '';
|
||||
list.innerHTML = '';
|
||||
status.textContent = 'Scanning paired devices...';
|
||||
status.style.display = '';
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
fetch('/bt_locate/paired_irks')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (btn) btn.disabled = false;
|
||||
const devices = data.devices || [];
|
||||
|
||||
if (devices.length === 0) {
|
||||
status.textContent = 'No paired devices with IRKs found';
|
||||
return;
|
||||
}
|
||||
|
||||
status.style.display = 'none';
|
||||
list.innerHTML = '';
|
||||
|
||||
devices.forEach(dev => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'btl-irk-picker-item';
|
||||
item.innerHTML =
|
||||
'<div class="btl-irk-picker-name">' + (dev.name || 'Unknown Device') + '</div>' +
|
||||
'<div class="btl-irk-picker-meta">' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '</div>';
|
||||
item.addEventListener('click', function() {
|
||||
selectPairedIrk(dev);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
if (btn) btn.disabled = false;
|
||||
console.error('[BtLocate] Failed to fetch paired IRKs:', err);
|
||||
status.textContent = 'Failed to read paired devices';
|
||||
});
|
||||
}
|
||||
|
||||
function selectPairedIrk(dev) {
|
||||
const irkInput = document.getElementById('btLocateIrk');
|
||||
const nameInput = document.getElementById('btLocateNamePattern');
|
||||
const picker = document.getElementById('btLocateIrkPicker');
|
||||
|
||||
if (irkInput) irkInput.value = dev.irk_hex;
|
||||
if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name;
|
||||
if (picker) picker.style.display = 'none';
|
||||
}
|
||||
|
||||
function clearTrail() {
|
||||
fetch('/bt_locate/clear_trail', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
clearMapMarkers();
|
||||
rssiHistory = [];
|
||||
drawRssiChart();
|
||||
updateStats(0, 0);
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Clear trail error:', err));
|
||||
}
|
||||
|
||||
function invalidateMap() {
|
||||
if (map) map.invalidateSize();
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
handoff,
|
||||
clearHandoff,
|
||||
setEnvironment,
|
||||
toggleAudio,
|
||||
clearTrail,
|
||||
handleDetection,
|
||||
invalidateMap,
|
||||
fetchPairedIrks,
|
||||
};
|
||||
})();
|
||||
+94
-14
@@ -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;">×</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 ==============
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;">
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- BT LOCATE MODE -->
|
||||
<div id="btLocateMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>BT Locate</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
SAR Bluetooth device location — GPS-tagged signal trail mapping with proximity alerts for locating missing persons' devices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Target Lock -->
|
||||
<div class="section">
|
||||
<h3>Target</h3>
|
||||
<div id="btLocateHandoffCard" style="display: none; background: rgba(0,255,136,0.08); border: 1px solid rgba(0,255,136,0.3); border-radius: 6px; padding: 8px; margin-bottom: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 10px; color: var(--accent-green); text-transform: uppercase; font-weight: 600;">Handed off from BT</span>
|
||||
<button onclick="BtLocate.clearHandoff()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 10px;">×</button>
|
||||
</div>
|
||||
<div id="btLocateHandoffName" style="font-size: 12px; font-weight: 600; color: var(--text-primary); margin-top: 4px;"></div>
|
||||
<div id="btLocateHandoffMeta" style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);"></div>
|
||||
</div>
|
||||
|
||||
<label class="input-label">MAC Address</label>
|
||||
<input type="text" id="btLocateMac" class="text-input" placeholder="AA:BB:CC:DD:EE:FF" style="font-family: var(--font-mono); font-size: 11px;">
|
||||
|
||||
<label class="input-label" style="margin-top: 6px;">Name Pattern</label>
|
||||
<input type="text" id="btLocateNamePattern" class="text-input" placeholder="iPhone, Galaxy, etc.">
|
||||
|
||||
<label class="input-label" style="margin-top: 6px;">IRK (hex, optional)</label>
|
||||
<div style="display: flex; gap: 4px; align-items: center;">
|
||||
<input type="text" id="btLocateIrk" class="text-input" placeholder="32 hex chars for RPA resolution" style="font-family: var(--font-mono); font-size: 10px; flex: 1;">
|
||||
<button class="btl-detect-irk-btn" id="btLocateDetectIrkBtn" onclick="BtLocate.fetchPairedIrks()" title="Detect IRKs from paired devices">Detect</button>
|
||||
</div>
|
||||
<div id="btLocateIrkPicker" class="btl-irk-picker" style="display: none;">
|
||||
<div id="btLocateIrkPickerStatus" class="btl-irk-picker-status"></div>
|
||||
<div id="btLocateIrkPickerList" class="btl-irk-picker-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Preset -->
|
||||
<div class="section">
|
||||
<h3>Environment</h3>
|
||||
<div class="btl-env-grid">
|
||||
<button class="btl-env-btn" data-env="FREE_SPACE" onclick="BtLocate.setEnvironment('FREE_SPACE')">
|
||||
<span class="btl-env-icon">🏠</span>
|
||||
<span class="btl-env-label">Open Field</span>
|
||||
<span class="btl-env-n">n=2.0</span>
|
||||
</button>
|
||||
<button class="btl-env-btn active" data-env="OUTDOOR" onclick="BtLocate.setEnvironment('OUTDOOR')">
|
||||
<span class="btl-env-icon">🌳</span>
|
||||
<span class="btl-env-label">Outdoor</span>
|
||||
<span class="btl-env-n">n=2.2</span>
|
||||
</button>
|
||||
<button class="btl-env-btn" data-env="INDOOR" onclick="BtLocate.setEnvironment('INDOOR')">
|
||||
<span class="btl-env-icon">🏢</span>
|
||||
<span class="btl-env-label">Indoor</span>
|
||||
<span class="btl-env-n">n=3.0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="section">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="run-btn" id="btLocateStartBtn" onclick="BtLocate.start()">Start Locate</button>
|
||||
<button class="stop-btn" id="btLocateStopBtn" onclick="BtLocate.stop()" style="display: none;">Stop</button>
|
||||
</div>
|
||||
<div id="btLocateScanStatus" style="display: none; margin-top: 6px; font-size: 10px; color: var(--text-dim);">
|
||||
<span id="btLocateScanDot" style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; margin-right: 4px; vertical-align: middle;"></span>
|
||||
<span id="btLocateScanText">BT scanner active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<!-- GPS MODE -->
|
||||
<div id="gpsMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>GPS Receiver</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Display live GPS data from gpsd — satellite sky view, signal strengths, position, velocity, DOP values, and timing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connection -->
|
||||
<div class="section">
|
||||
<h3>Connection</h3>
|
||||
<div class="gps-connection-status">
|
||||
<span class="gps-status-dot" id="gpsStatusDot"></span>
|
||||
<span class="gps-status-text" id="gpsStatusText">Disconnected</span>
|
||||
</div>
|
||||
<div id="gpsDevicePath" style="font-size: 10px; color: var(--text-dim); margin-top: 4px; font-family: var(--font-mono);"></div>
|
||||
<div style="display: flex; gap: 6px; margin-top: 8px;">
|
||||
<button class="run-btn" id="gpsConnectBtn" onclick="GPS.connect()">Connect</button>
|
||||
<button class="stop-btn" id="gpsDisconnectBtn" onclick="GPS.disconnect()" style="display: none;">Disconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fix Info -->
|
||||
<div class="section">
|
||||
<h3>Fix</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Fix Type</span>
|
||||
<span class="gps-info-value" id="gpsFixType">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Satellites</span>
|
||||
<span class="gps-info-value"><span id="gpsSatUsed">-</span> / <span id="gpsSatTotal">-</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Position -->
|
||||
<div class="section">
|
||||
<h3>Position</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Latitude</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsLat">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Longitude</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsLon">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Altitude</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsAlt">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Speed</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsSpeed">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Heading</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsHeading">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">Climb</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsClimb">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DOP Values -->
|
||||
<div class="section">
|
||||
<h3>Dilution of Precision</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">HDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsHdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">VDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsVdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">PDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsPdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">TDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsTdop">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">GDOP</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsGdop">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Estimates -->
|
||||
<div class="section">
|
||||
<h3>Error Estimates</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">EPH (horiz)</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsEph">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">EPV (vert)</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsEpv">---</span>
|
||||
</div>
|
||||
<div class="gps-info-item">
|
||||
<span class="gps-info-label">EPS (speed)</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsEps">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timing -->
|
||||
<div class="section">
|
||||
<h3>GPS Time</h3>
|
||||
<div class="gps-info-grid">
|
||||
<div class="gps-info-item" style="grid-column: 1 / -1;">
|
||||
<span class="gps-info-label">UTC</span>
|
||||
<span class="gps-info-value gps-mono" id="gpsTime" style="font-size: 14px;">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);">▼</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
IRK Extractor — Extract Identity Resolving Keys from paired Bluetooth devices.
|
||||
|
||||
Supports macOS (com.apple.Bluetooth.plist) and Linux (BlueZ info files).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger('intercept.bt.irk_extractor')
|
||||
|
||||
# Cache paired IRKs for 30 seconds to avoid repeated disk reads
|
||||
_cache: list[dict] | None = None
|
||||
_cache_time: float = 0
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
def get_paired_irks() -> list[dict]:
|
||||
"""Return paired Bluetooth devices that have IRKs.
|
||||
|
||||
Each entry is a dict with keys:
|
||||
- name: Device name (str or None)
|
||||
- address: Bluetooth address (str)
|
||||
- irk_hex: 32-char hex string of the 16-byte IRK
|
||||
- address_type: 'random' or 'public' (str or None)
|
||||
|
||||
Results are cached for 30 seconds.
|
||||
"""
|
||||
global _cache, _cache_time
|
||||
|
||||
now = time.monotonic()
|
||||
if _cache is not None and (now - _cache_time) < _CACHE_TTL:
|
||||
return _cache
|
||||
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == 'Darwin':
|
||||
results = _extract_macos()
|
||||
elif system == 'Linux':
|
||||
results = _extract_linux()
|
||||
else:
|
||||
logger.debug(f"IRK extraction not supported on {system}")
|
||||
results = []
|
||||
except Exception:
|
||||
logger.exception("Failed to extract paired IRKs")
|
||||
results = []
|
||||
|
||||
_cache = results
|
||||
_cache_time = now
|
||||
return results
|
||||
|
||||
|
||||
def _extract_macos() -> list[dict]:
|
||||
"""Extract IRKs from macOS Bluetooth plist."""
|
||||
import plistlib
|
||||
|
||||
plist_path = Path('/Library/Preferences/com.apple.Bluetooth.plist')
|
||||
if not plist_path.exists():
|
||||
logger.debug("macOS Bluetooth plist not found")
|
||||
return []
|
||||
|
||||
with open(plist_path, 'rb') as f:
|
||||
plist = plistlib.load(f)
|
||||
|
||||
devices = []
|
||||
|
||||
cache_data = plist.get('CoreBluetoothCache', {})
|
||||
|
||||
# CoreBluetoothCache contains BLE device info including IRKs
|
||||
for device_uuid, device_info in cache_data.items():
|
||||
if not isinstance(device_info, dict):
|
||||
continue
|
||||
|
||||
irk = device_info.get('IRK')
|
||||
if irk is None:
|
||||
continue
|
||||
|
||||
# IRK is stored as bytes (16 bytes)
|
||||
if isinstance(irk, bytes) and len(irk) == 16:
|
||||
irk_hex = irk.hex()
|
||||
elif isinstance(irk, str):
|
||||
irk_hex = irk.replace('-', '').replace(' ', '')
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
name = device_info.get('Name') or device_info.get('DeviceName')
|
||||
address = device_info.get('DeviceAddress', device_uuid)
|
||||
addr_type = 'random' if device_info.get('AddressType', 1) == 1 else 'public'
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': str(address),
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
|
||||
# Also check LEPairedDevices / PairedDevices structures
|
||||
for section_key in ('LEPairedDevices', 'PairedDevices'):
|
||||
section = plist.get(section_key, {})
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
for addr, dev_info in section.items():
|
||||
if not isinstance(dev_info, dict):
|
||||
continue
|
||||
irk = dev_info.get('IRK') or dev_info.get('IdentityResolvingKey')
|
||||
if irk is None:
|
||||
continue
|
||||
|
||||
if isinstance(irk, bytes) and len(irk) == 16:
|
||||
irk_hex = irk.hex()
|
||||
elif isinstance(irk, str):
|
||||
irk_hex = irk.replace('-', '').replace(' ', '')
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Skip if we already have this IRK
|
||||
if any(d['irk_hex'] == irk_hex for d in devices):
|
||||
continue
|
||||
|
||||
name = dev_info.get('Name') or dev_info.get('DeviceName')
|
||||
addr_type = 'random' if dev_info.get('AddressType', 1) == 1 else 'public'
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': str(addr),
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
|
||||
logger.info(f"Extracted {len(devices)} IRK(s) from macOS paired devices")
|
||||
return devices
|
||||
|
||||
|
||||
def _extract_linux() -> list[dict]:
|
||||
"""Extract IRKs from Linux BlueZ info files.
|
||||
|
||||
BlueZ stores paired device info at:
|
||||
/var/lib/bluetooth/<adapter_mac>/<device_mac>/info
|
||||
"""
|
||||
import configparser
|
||||
|
||||
bt_root = Path('/var/lib/bluetooth')
|
||||
if not bt_root.exists():
|
||||
logger.debug("BlueZ bluetooth directory not found")
|
||||
return []
|
||||
|
||||
devices = []
|
||||
|
||||
for adapter_dir in bt_root.iterdir():
|
||||
if not adapter_dir.is_dir():
|
||||
continue
|
||||
for device_dir in adapter_dir.iterdir():
|
||||
if not device_dir.is_dir():
|
||||
continue
|
||||
|
||||
info_file = device_dir / 'info'
|
||||
if not info_file.exists():
|
||||
continue
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(str(info_file))
|
||||
except (configparser.Error, OSError):
|
||||
continue
|
||||
|
||||
if not config.has_section('IdentityResolvingKey'):
|
||||
continue
|
||||
|
||||
irk_hex = config.get('IdentityResolvingKey', 'Key', fallback=None)
|
||||
if not irk_hex:
|
||||
continue
|
||||
|
||||
# BlueZ stores as hex string, may or may not have separators
|
||||
irk_hex = irk_hex.replace(' ', '').replace('-', '')
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
|
||||
name = config.get('General', 'Name', fallback=None)
|
||||
address = device_dir.name # Directory name is the MAC address
|
||||
addr_type = config.get('General', 'AddressType', fallback=None)
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': address,
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
|
||||
logger.info(f"Extracted {len(devices)} IRK(s) from BlueZ paired devices")
|
||||
return devices
|
||||
@@ -66,7 +66,7 @@ class BluetoothScanner:
|
||||
self._scan_timer: Optional[threading.Timer] = None
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)])
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+19
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user