mirror of
https://github.com/smittix/intercept.git
synced 2026-06-22 12:03:05 -07:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99d52eafe7 | |||
| 2a73318457 | |||
| d8d08a8b1e | |||
| c60769f795 | |||
| 01f8324292 | |||
| c66988cc1c | |||
| fac3d4359b | |||
| d6f10d29ca | |||
| 332735cecf | |||
| b04e335f49 | |||
| 75e50a1cd4 | |||
| 243a0f0e7f | |||
| 7c3ec9e920 | |||
| 4639146f05 | |||
| a354fee792 | |||
| a1cb6b2692 | |||
| 8376415074 | |||
| b25615317b | |||
| 311d268b10 | |||
| 6581620cb0 | |||
| aa963519e9 | |||
| 4a6dddbb48 | |||
| f217230ef4 | |||
| e27b4d78cb | |||
| d41ba61aee | |||
| 35cf01c11e | |||
| 00c9a6fdd9 | |||
| fce66a6a60 | |||
| b023e4cdc7 | |||
| a8f2912b90 | |||
| a2a7ac8fec | |||
| 4e168ff502 | |||
| 54c849ab60 | |||
| 94ee22fdd4 | |||
| ca15e227cd | |||
| 1924203c19 | |||
| fd0953bfb5 | |||
| 13be4302c3 | |||
| 5fd45d3e94 | |||
| e88b815dc9 | |||
| 556a4ffcc2 | |||
| 03c5d33eb7 | |||
| f9786aa75a | |||
| b87623cf66 | |||
| 4d24e648ab | |||
| 99f42f66b2 | |||
| 4bf35cf786 | |||
| 4ed7969e90 | |||
| 1683d98b90 | |||
| ff36687f53 | |||
| b860a4309b | |||
| f409222f8a | |||
| 1c051933b7 | |||
| c83a2ef56f | |||
| 6d1f8f022e | |||
| 500ddf59fe | |||
| 5e4be0c279 | |||
| 7b68c19dc5 | |||
| 780ba9c58b |
@@ -55,6 +55,12 @@ intercept_agent_*.cfg
|
|||||||
/tmp/
|
/tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
||||||
|
data/weather_sat/
|
||||||
|
|
||||||
|
# SDR capture files (large IQ recordings)
|
||||||
|
data/subghz/captures/
|
||||||
|
|
||||||
# Env files
|
# Env files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -4,11 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking.
|
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
### Setup and Running
|
### Docker (Primary)
|
||||||
|
```bash
|
||||||
|
# Build and run (basic profile)
|
||||||
|
docker compose --profile basic up -d
|
||||||
|
|
||||||
|
# Build and run with ADS-B history (Postgres)
|
||||||
|
docker compose --profile history up -d
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker compose --profile basic up -d --build
|
||||||
|
|
||||||
|
# Multi-arch build (amd64 + arm64 for RPi)
|
||||||
|
./build-multiarch.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Setup (Alternative)
|
||||||
```bash
|
```bash
|
||||||
# Initial setup (installs dependencies and configures SDR tools)
|
# Initial setup (installs dependencies and configures SDR tools)
|
||||||
./setup.sh
|
./setup.sh
|
||||||
@@ -66,8 +81,12 @@ Each signal type has its own Flask blueprint:
|
|||||||
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||||
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||||
- `satellite.py` - Pass prediction using TLE data
|
- `satellite.py` - Pass prediction using TLE data
|
||||||
|
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||||
|
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||||
|
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||||
- `aprs.py` - Amateur packet radio via direwolf
|
- `aprs.py` - Amateur packet radio via direwolf
|
||||||
- `rtlamr.py` - Utility meter reading
|
- `rtlamr.py` - Utility meter reading
|
||||||
|
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||||
|
|
||||||
### Core Utilities (utils/)
|
### Core Utilities (utils/)
|
||||||
|
|
||||||
@@ -91,6 +110,15 @@ Each signal type has its own Flask blueprint:
|
|||||||
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||||
- `channel_analyzer.py` - Frequency band analysis
|
- `channel_analyzer.py` - Frequency band analysis
|
||||||
|
|
||||||
|
**Weather Satellite** (`utils/weather_sat.py`):
|
||||||
|
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||||
|
- Subprocess management with stdout parsing, image watcher via rglob
|
||||||
|
- Pass prediction using skyfield TLE data
|
||||||
|
|
||||||
|
**SSTV Decoder** (`utils/sstv.py`):
|
||||||
|
- ISS SSTV reception via slowrx with Doppler tracking
|
||||||
|
- Singleton pattern, image gallery with timestamped filenames
|
||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
||||||
@@ -112,9 +140,25 @@ Each signal type has its own Flask blueprint:
|
|||||||
| acarsdec | ACARS messages | Output parsing |
|
| acarsdec | ACARS messages | Output parsing |
|
||||||
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||||
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||||
|
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||||
|
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||||
|
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||||
|
| direwolf | APRS | TNC modem for packet radio |
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||||
|
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||||
|
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||||
|
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
|
||||||
|
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||||
|
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||||
|
- Data persisted via `./data:/app/data` volume mount
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix
|
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
||||||
- Database: SQLite in `instance/` directory for settings, baselines, history
|
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|||||||
+70
-1
@@ -24,6 +24,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
multimon-ng \
|
multimon-ng \
|
||||||
# Audio tools for Listening Post
|
# Audio tools for Listening Post
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
# SSTV decoder runtime libs
|
||||||
|
libsndfile1 \
|
||||||
|
# SatDump runtime libs (weather satellite decoding)
|
||||||
|
libpng16-16 \
|
||||||
|
libtiff6 \
|
||||||
|
libjemalloc2 \
|
||||||
|
libvolk-bin \
|
||||||
|
libnng1 \
|
||||||
|
libzstd1 \
|
||||||
# WiFi tools (aircrack-ng suite)
|
# WiFi tools (aircrack-ng suite)
|
||||||
aircrack-ng \
|
aircrack-ng \
|
||||||
iw \
|
iw \
|
||||||
@@ -32,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
bluez \
|
bluez \
|
||||||
bluetooth \
|
bluetooth \
|
||||||
# GPS support
|
# GPS support
|
||||||
|
gpsd \
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
# Utilities
|
# Utilities
|
||||||
# APRS
|
# APRS
|
||||||
@@ -61,9 +71,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cmake \
|
cmake \
|
||||||
libncurses-dev \
|
libncurses-dev \
|
||||||
libsndfile1-dev \
|
libsndfile1-dev \
|
||||||
|
# GTK is required for slowrx (SSTV decoder GUI dependency).
|
||||||
|
# Note: slowrx is kept for backwards compatibility, but the pure Python
|
||||||
|
# SSTV decoder in utils/sstv/ is now the primary implementation.
|
||||||
|
# GTK can be removed if slowrx is deprecated in future releases.
|
||||||
|
libgtk-3-dev \
|
||||||
|
libasound2-dev \
|
||||||
libsoapysdr-dev \
|
libsoapysdr-dev \
|
||||||
libhackrf-dev \
|
libhackrf-dev \
|
||||||
liblimesuite-dev \
|
liblimesuite-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
@@ -118,6 +141,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& make \
|
&& make \
|
||||||
&& cp acarsdec /usr/bin/acarsdec \
|
&& cp acarsdec /usr/bin/acarsdec \
|
||||||
&& rm -rf /tmp/acarsdec \
|
&& rm -rf /tmp/acarsdec \
|
||||||
|
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/windytan/slowrx.git \
|
||||||
|
&& cd slowrx \
|
||||||
|
&& git checkout ca6d7012 \
|
||||||
|
&& make \
|
||||||
|
&& install -m 0755 slowrx /usr/local/bin/slowrx \
|
||||||
|
&& rm -rf /tmp/slowrx \
|
||||||
|
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||||
|
&& cd SatDump \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
|
||||||
|
&& mkdir -p /usr/local/lib/satdump/plugins \
|
||||||
|
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
|
||||||
|
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
|
||||||
|
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
|
||||||
|
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
fi \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/SatDump \
|
||||||
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||||
|
&& export PATH="$PATH:/usr/local/go/bin" \
|
||||||
|
&& export GOPATH=/tmp/gopath \
|
||||||
|
&& go install github.com/bemasher/rtlamr@latest \
|
||||||
|
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||||
|
&& rm -rf /usr/local/go /tmp/gopath \
|
||||||
# Build mbelib (required by DSD)
|
# Build mbelib (required by DSD)
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone https://github.com/lwvmobile/mbelib.git \
|
&& git clone https://github.com/lwvmobile/mbelib.git \
|
||||||
@@ -140,6 +200,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& ldconfig \
|
&& ldconfig \
|
||||||
&& rm -rf /tmp/dsd-fme \
|
&& rm -rf /tmp/dsd-fme \
|
||||||
# Cleanup build tools to reduce image size
|
# Cleanup build tools to reduce image size
|
||||||
|
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||||
&& apt-get remove -y \
|
&& apt-get remove -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
git \
|
git \
|
||||||
@@ -147,6 +208,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cmake \
|
cmake \
|
||||||
libncurses-dev \
|
libncurses-dev \
|
||||||
libsndfile1-dev \
|
libsndfile1-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
libsoapysdr-dev \
|
libsoapysdr-dev \
|
||||||
libhackrf-dev \
|
libhackrf-dev \
|
||||||
liblimesuite-dev \
|
liblimesuite-dev \
|
||||||
@@ -169,7 +238,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory for persistence
|
# Create data directory for persistence
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data /app/data/weather_sat
|
||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
|
|||||||
@@ -1,21 +1,200 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 smittix
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
1. Definitions.
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
the copyright owner that is granting the License.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by the Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding any notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. Please also get an OpenPGP
|
||||||
|
key and encrypt outgoing communications.
|
||||||
|
|
||||||
|
Copyright 2025 smittix
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|||||||
@@ -28,18 +28,23 @@ Support the developer of this open-source project
|
|||||||
|
|
||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
|
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
|
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
||||||
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
|
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
||||||
- **ISS SSTV** - Receive slow-scan TV from the International Space Station
|
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
||||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
|
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||||
|
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
|
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||||
|
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||||
- **Meshtastic** - LoRa mesh network integration
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
@@ -60,15 +65,54 @@ cd intercept
|
|||||||
sudo -E venv/bin/python intercept.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker (Alternative)
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker compose up -d
|
docker compose --profile basic up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
|
||||||
|
|
||||||
|
#### Multi-Architecture Builds (amd64 + arm64)
|
||||||
|
|
||||||
|
Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time setup on your x64 build machine
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
docker buildx create --name intercept-builder --use --bootstrap
|
||||||
|
|
||||||
|
# Build and push for both architectures
|
||||||
|
REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
|
||||||
|
|
||||||
|
# On the RPi5, just pull and run
|
||||||
|
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Build script options:
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `--push` | Push to container registry |
|
||||||
|
| `--load` | Load into local Docker (single platform only) |
|
||||||
|
| `--arm64-only` | Build arm64 only (for RPi deployment) |
|
||||||
|
| `--amd64-only` | Build amd64 only |
|
||||||
|
|
||||||
|
Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
|
||||||
|
|
||||||
|
#### Using a Pre-built Image
|
||||||
|
|
||||||
|
If you've pushed to a registry, you can skip building entirely on the target machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set in .env or export
|
||||||
|
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
||||||
|
|
||||||
|
# Then just run
|
||||||
|
docker compose --profile basic up -d
|
||||||
|
```
|
||||||
|
|
||||||
### ADS-B History (Optional)
|
### ADS-B History (Optional)
|
||||||
|
|
||||||
@@ -184,7 +228,7 @@ This project was developed using AI as a coding partner, combining human directi
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
Apache 2.0 License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
@@ -198,8 +242,11 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[dump1090](https://github.com/flightaware/dump1090) |
|
[dump1090](https://github.com/flightaware/dump1090) |
|
||||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
|
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
|
[SatDump](https://github.com/SatDump/SatDump) |
|
||||||
[Celestrak](https://celestrak.org/) |
|
[Celestrak](https://celestrak.org/) |
|
||||||
[Priyom.org](https://priyom.org/)
|
[Priyom.org](https://priyom.org/)
|
||||||
|
|
||||||
@@ -211,3 +258,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
from utils.process import cleanup_stale_processes
|
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
from utils.cleanup import DataStore, cleanup_manager
|
from utils.cleanup import DataStore, cleanup_manager
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -182,6 +182,10 @@ dmr_lock = threading.Lock()
|
|||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
|
# SubGHz Transceiver (HackRF)
|
||||||
|
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
subghz_lock = threading.Lock()
|
||||||
|
|
||||||
# Deauth Attack Detection
|
# Deauth Attack Detection
|
||||||
deauth_detector = None
|
deauth_detector = None
|
||||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
@@ -370,6 +374,8 @@ def index() -> str:
|
|||||||
version=VERSION,
|
version=VERSION,
|
||||||
changelog=CHANGELOG,
|
changelog=CHANGELOG,
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
default_latitude=DEFAULT_LATITUDE,
|
||||||
|
default_longitude=DEFAULT_LONGITUDE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -641,6 +647,25 @@ def export_bluetooth() -> 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')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
@@ -659,7 +684,8 @@ def health_check() -> Response:
|
|||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_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),
|
'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),
|
'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),
|
'dmr': _get_dmr_active(),
|
||||||
|
'subghz': _get_subghz_active(),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
@@ -689,8 +715,9 @@ def kill_all() -> Response:
|
|||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'dsd',
|
'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:
|
for proc in processes_to_kill:
|
||||||
@@ -759,6 +786,13 @@ def kill_all() -> Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Reset SubGHz state
|
||||||
|
try:
|
||||||
|
from utils.subghz import get_subghz_manager
|
||||||
|
get_subghz_manager().stop_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Clear SDR device registry
|
# Clear SDR device registry
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
sdr_device_registry.clear()
|
sdr_device_registry.clear()
|
||||||
@@ -843,6 +877,7 @@ def main() -> None:
|
|||||||
|
|
||||||
# Clean up any stale processes from previous runs
|
# Clean up any stale processes from previous runs
|
||||||
cleanup_stale_processes()
|
cleanup_stale_processes()
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
|
||||||
# Initialize database for settings storage
|
# Initialize database for settings storage
|
||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
@@ -867,6 +902,15 @@ def main() -> None:
|
|||||||
from routes import register_blueprints
|
from routes import register_blueprints
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||||
|
try:
|
||||||
|
from routes.satellite import init_tle_auto_refresh
|
||||||
|
import os
|
||||||
|
if not os.environ.get('TESTING'):
|
||||||
|
init_tle_auto_refresh()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||||
|
|
||||||
# Update TLE data in background thread (non-blocking)
|
# Update TLE data in background thread (non-blocking)
|
||||||
def update_tle_background():
|
def update_tle_background():
|
||||||
try:
|
try:
|
||||||
|
|||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# INTERCEPT - Multi-architecture Docker image builder
|
||||||
|
#
|
||||||
|
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
||||||
|
# Run this on your x64 machine to cross-compile the arm64 image
|
||||||
|
# instead of building natively on the RPi5.
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time setup):
|
||||||
|
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
# docker buildx create --name intercept-builder --use --bootstrap
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-multiarch.sh # Build both platforms, load locally
|
||||||
|
# ./build-multiarch.sh --push # Build and push to registry
|
||||||
|
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
||||||
|
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
||||||
|
#
|
||||||
|
# Environment variables:
|
||||||
|
# REGISTRY - Container registry (default: docker.io/library)
|
||||||
|
# IMAGE_NAME - Image name (default: intercept)
|
||||||
|
# IMAGE_TAG - Image tag (default: latest)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY="${REGISTRY:-}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
|
BUILDER_NAME="intercept-builder"
|
||||||
|
PLATFORMS="linux/amd64,linux/arm64"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
PUSH=false
|
||||||
|
LOAD=false
|
||||||
|
ARM64_ONLY=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--push) PUSH=true ;;
|
||||||
|
--load) LOAD=true ;;
|
||||||
|
--arm64-only)
|
||||||
|
ARM64_ONLY=true
|
||||||
|
PLATFORMS="linux/arm64"
|
||||||
|
;;
|
||||||
|
--amd64-only)
|
||||||
|
PLATFORMS="linux/amd64"
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --push Push to container registry"
|
||||||
|
echo " --load Load into local Docker (single platform only)"
|
||||||
|
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
||||||
|
echo " --amd64-only Build amd64 only"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
||||||
|
echo " IMAGE_NAME Image name (default: intercept)"
|
||||||
|
echo " IMAGE_TAG Image tag (default: latest)"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 --push # Build both, push"
|
||||||
|
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
||||||
|
echo " $0 --arm64-only --load # Build arm64, load locally"
|
||||||
|
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $arg"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build full image reference
|
||||||
|
if [ -n "$REGISTRY" ]; then
|
||||||
|
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
else
|
||||||
|
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " INTERCEPT Multi-Architecture Builder"
|
||||||
|
echo "============================================"
|
||||||
|
echo " Image: ${FULL_IMAGE}"
|
||||||
|
echo " Platforms: ${PLATFORMS}"
|
||||||
|
echo " Push: ${PUSH}"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if buildx builder exists, create if not
|
||||||
|
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||||
|
echo "Creating buildx builder: ${BUILDER_NAME}"
|
||||||
|
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||||
|
|
||||||
|
# Check for QEMU support
|
||||||
|
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
||||||
|
echo "WARNING: QEMU binfmt setup may have failed."
|
||||||
|
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
docker buildx use "$BUILDER_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
||||||
|
|
||||||
|
if [ "$PUSH" = true ]; then
|
||||||
|
BUILD_CMD="${BUILD_CMD} --push"
|
||||||
|
echo "Will push to: ${FULL_IMAGE}"
|
||||||
|
elif [ "$LOAD" = true ]; then
|
||||||
|
# --load only works with single platform
|
||||||
|
if echo "$PLATFORMS" | grep -q ","; then
|
||||||
|
echo "ERROR: --load only works with a single platform."
|
||||||
|
echo "Use --arm64-only or --amd64-only with --load."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BUILD_CMD="${BUILD_CMD} --load"
|
||||||
|
echo "Will load into local Docker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building..."
|
||||||
|
echo "Command: ${BUILD_CMD} ."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
$BUILD_CMD .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " Build complete!"
|
||||||
|
if [ "$PUSH" = true ]; then
|
||||||
|
echo " Image pushed to: ${FULL_IMAGE}"
|
||||||
|
echo ""
|
||||||
|
echo " Pull on RPi5:"
|
||||||
|
echo " docker pull ${FULL_IMAGE}"
|
||||||
|
fi
|
||||||
|
echo "============================================"
|
||||||
@@ -7,10 +7,44 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.15.0"
|
VERSION = "2.18.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.18.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
|
||||||
|
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
|
||||||
|
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
|
||||||
|
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
||||||
|
"GPS: error state indicator and UI refinements",
|
||||||
|
"Proximity radar and signal card UI improvements",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.17.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
|
||||||
|
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
|
||||||
|
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
|
||||||
|
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.16.0",
|
||||||
|
"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",
|
"version": "2.15.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
@@ -211,12 +245,32 @@ ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
|||||||
|
|
||||||
# Observer location settings
|
# Observer location settings
|
||||||
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
||||||
|
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
|
||||||
|
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
|
||||||
|
|
||||||
# Satellite settings
|
# Satellite settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||||
|
|
||||||
|
# Weather satellite settings
|
||||||
|
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||||
|
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
|
||||||
|
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
|
||||||
|
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
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
|
|||||||
+28
-10
@@ -1,27 +1,31 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker Compose configuration for easy deployment
|
# Docker Compose configuration for easy deployment
|
||||||
#
|
#
|
||||||
# Basic usage:
|
# Basic usage (build locally):
|
||||||
# docker compose up -d
|
# docker compose --profile basic up -d --build
|
||||||
|
#
|
||||||
|
# Basic usage (pre-built image from registry):
|
||||||
|
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
||||||
#
|
#
|
||||||
# With ADS-B history (Postgres):
|
# With ADS-B history (Postgres):
|
||||||
# docker compose --profile history up -d
|
# docker compose --profile history up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
intercept:
|
intercept:
|
||||||
|
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
||||||
|
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept
|
container_name: intercept
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
# Alternatively, use device mapping (see below)
|
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping (alternative to privileged mode)
|
# USB device mapping for all USB devices
|
||||||
# devices:
|
devices:
|
||||||
# - /dev/bus/usb:/dev/bus/usb
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
# volumes:
|
volumes:
|
||||||
# Persist data directory
|
# Persist decoded images and database across container rebuilds
|
||||||
# - ./data:/app/data
|
- ./data:/app/data
|
||||||
# Optional: mount logs directory
|
# Optional: mount logs directory
|
||||||
# - ./logs:/app/logs
|
# - ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
@@ -40,6 +44,9 @@ services:
|
|||||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
# Shared observer location across modules
|
# Shared observer location across modules
|
||||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
|
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||||
|
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||||
|
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||||
# Network mode for WiFi scanning (requires host network)
|
# Network mode for WiFi scanning (requires host network)
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -53,15 +60,23 @@ services:
|
|||||||
# ADS-B history with Postgres persistence
|
# ADS-B history with Postgres persistence
|
||||||
# Enable with: docker compose --profile history up -d
|
# Enable with: docker compose --profile history up -d
|
||||||
intercept-history:
|
intercept-history:
|
||||||
|
# Same image/build fallback pattern as above
|
||||||
|
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept
|
container_name: intercept-history
|
||||||
profiles:
|
profiles:
|
||||||
- history
|
- history
|
||||||
depends_on:
|
depends_on:
|
||||||
- adsb_db
|
- adsb_db
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
|
# USB device mapping for all USB devices
|
||||||
|
devices:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
@@ -76,6 +91,9 @@ services:
|
|||||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
# Shared observer location across modules
|
# Shared observer location across modules
|
||||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
|
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||||
|
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||||
|
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ Complete feature list for all modules.
|
|||||||
- **Doorbells, remotes, and IoT devices**
|
- **Doorbells, remotes, and IoT devices**
|
||||||
- **Smart meters** and utility monitors
|
- **Smart meters** and utility monitors
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
|
||||||
|
- **Protocol decoding** - identify and decode common Sub-GHz protocols
|
||||||
|
- **Signal replay/transmit** capabilities for authorized testing
|
||||||
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
|
- **I/Q capture** - record raw samples for offline analysis
|
||||||
|
|
||||||
## AIS Vessel Tracking
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||||
@@ -84,6 +92,55 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||||
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||||
|
|
||||||
|
## ACARS Messaging
|
||||||
|
|
||||||
|
- **Real-time ACARS decoding** via acarsdec
|
||||||
|
- **Aircraft datalink messages** - operational, weather, and position reports
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
- **Message filtering** - filter by message type, flight, or registration
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||||
|
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||||
|
- **Cross-module frequency routing** from scanner to decoders
|
||||||
|
- **Customizable frequency presets** and band bookmarks
|
||||||
|
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
|
||||||
|
- **Auto-scheduler** with pass prediction and automatic capture
|
||||||
|
- **Polar plot** - real-time satellite position on azimuth/elevation display
|
||||||
|
- **Ground track map** - orbit path with past/future trajectory
|
||||||
|
- **Image gallery** with timestamped decoded imagery
|
||||||
|
|
||||||
|
## WebSDR
|
||||||
|
|
||||||
|
- **KiwiSDR network integration** for remote HF/shortwave listening
|
||||||
|
- **WebSocket audio streaming** from remote receivers
|
||||||
|
- **Receiver discovery** with automatic caching
|
||||||
|
- **Frequency tuning** with band presets
|
||||||
|
|
||||||
|
## HF SSTV
|
||||||
|
|
||||||
|
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||||
|
- **Predefined frequency lookup** for active SSTV calling frequencies
|
||||||
|
- **Image gallery** with decoded transmissions
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
- **Amateur packet radio** position reports and telemetry via direwolf
|
||||||
|
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
|
||||||
|
- **Real-time position tracking** on interactive map
|
||||||
|
- **Message and telemetry display** from APRS network
|
||||||
|
|
||||||
|
## Utility Meter Reading
|
||||||
|
|
||||||
|
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
|
||||||
|
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||||
|
- **Multiple meter protocol support** via rtl_tcp integration
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
@@ -131,6 +188,52 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **Proximity radar** visualization
|
- **Proximity radar** visualization
|
||||||
- **Device type breakdown** chart
|
- **Device type breakdown** chart
|
||||||
|
|
||||||
|
## BT Locate (SAR Bluetooth Device Location)
|
||||||
|
|
||||||
|
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
||||||
|
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
||||||
|
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
||||||
|
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
||||||
|
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
||||||
|
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
||||||
|
- **Distance estimation** - Log-distance path loss model with environment presets
|
||||||
|
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
||||||
|
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
||||||
|
|
||||||
|
### Environment Presets
|
||||||
|
- **Open Field** (n=2.0) - Free space path loss
|
||||||
|
- **Outdoor** (n=2.2) - Typical outdoor environment
|
||||||
|
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
||||||
|
|
||||||
|
### Map & Trail
|
||||||
|
- Interactive Leaflet map with GPS trail visualization
|
||||||
|
- Trail points color-coded by proximity band
|
||||||
|
- Polyline connecting detection points for path visualization
|
||||||
|
- Supports user-configured tile providers
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Bluetooth adapter (built-in or USB)
|
||||||
|
- GPS receiver (optional, falls back to manual coordinates)
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
Real-time GPS position tracking with live map visualization.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
||||||
|
- **Interactive map** - Current position on Leaflet map with track history
|
||||||
|
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
||||||
|
- **Satellite info** - Number of satellites in view and fix quality
|
||||||
|
- **Track recording** - Record GPS tracks with export capability
|
||||||
|
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- USB GPS receiver connected via gpsd
|
||||||
|
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
||||||
|
|
||||||
## TSCM Counter-Surveillance Mode
|
## TSCM Counter-Surveillance Mode
|
||||||
|
|
||||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||||
|
|||||||
+204
-76
@@ -57,92 +57,120 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- Samsung SmartTag
|
- Samsung SmartTag
|
||||||
- Chipolo
|
- Chipolo
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
1. **Connect HackRF** - Plug in your HackRF One device
|
||||||
|
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
|
||||||
|
3. **Start Capture** - Click "Start Capture" to begin signal analysis
|
||||||
|
4. **View Spectrum** - Real-time spectrum visualization of the selected band
|
||||||
|
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
|
||||||
|
|
||||||
|
### Supported Protocols
|
||||||
|
|
||||||
|
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency Range** - Define start and end frequencies for scanning
|
||||||
|
3. **Start Scanning** - Click "Start Scan" for wideband sweep
|
||||||
|
4. **View Signals** - Discovered signals are listed with frequency and SNR
|
||||||
|
5. **Tune In** - Click a signal to tune the audio demodulator
|
||||||
|
6. **Listen** - Real-time audio plays in your browser
|
||||||
|
|
||||||
|
### Demodulation Modes
|
||||||
|
|
||||||
|
- **FM** - Narrowband and wideband FM
|
||||||
|
- **SSB** - Upper and lower sideband for amateur radio and shortwave
|
||||||
|
|
||||||
## Aircraft Mode (ADS-B)
|
## Aircraft Mode (ADS-B)
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||||
2. **Check Tools** - Ensure dump1090 or readsb is installed
|
2. **Check Tools** - Ensure dump1090 or readsb is installed
|
||||||
3. **Set Location** - Choose location source:
|
3. **Set Location** - Choose location source:
|
||||||
- **Manual Entry** - Type coordinates directly
|
- **Manual Entry** - Type coordinates directly
|
||||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||||
- **Shared Location** - By default, the observer location is shared across modules
|
- **Shared Location** - By default, the observer location is shared across modules
|
||||||
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
||||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||||
6. **Click Aircraft** - Click markers for detailed information
|
6. **Click Aircraft** - Click markers for detailed information
|
||||||
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
|
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
|
||||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
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,
|
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
|
||||||
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
||||||
|
|
||||||
### Emergency Squawks
|
### Emergency Squawks
|
||||||
|
|
||||||
The system highlights aircraft transmitting emergency squawks:
|
The system highlights aircraft transmitting emergency squawks:
|
||||||
- **7500** - Hijack
|
- **7500** - Hijack
|
||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
## ADS-B History (Optional)
|
## ADS-B History (Optional)
|
||||||
|
|
||||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||||
|
|
||||||
### Enable History
|
### Enable History
|
||||||
|
|
||||||
Set the following environment variables (Docker recommended):
|
Set the following environment variables (Docker recommended):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
| `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_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||||
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||||
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||||
|
|
||||||
### Other ADS-B Settings
|
### Other ADS-B Settings
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
| `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 |
|
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||||
|
|
||||||
**Local install example**
|
**Local install example**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true
|
INTERCEPT_ADSB_AUTO_START=true
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Setup
|
### Docker Setup
|
||||||
|
|
||||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile history up -d
|
docker compose --profile history up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using the History Dashboard
|
### Using the History Dashboard
|
||||||
|
|
||||||
1. Open **/adsb/history**
|
1. Open **/adsb/history**
|
||||||
2. Use **Start Tracking** to run ADS-B in headless mode
|
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||||
3. View aircraft history and timelines
|
3. View aircraft history and timelines
|
||||||
4. Stop tracking when desired (session history is recorded)
|
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
|
## Satellite Mode
|
||||||
|
|
||||||
@@ -163,6 +191,106 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
|||||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||||
4. Select satellites to add
|
4. Select satellites to add
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
1. **Set Location** - Enter observer coordinates or use GPS
|
||||||
|
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
|
||||||
|
3. **View Passes** - Upcoming passes shown with polar plot and ground track
|
||||||
|
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
|
||||||
|
5. **View Images** - Decoded imagery appears in the gallery
|
||||||
|
|
||||||
|
### Auto-Scheduler
|
||||||
|
|
||||||
|
Enable the auto-scheduler to automatically capture passes:
|
||||||
|
- Calculates upcoming NOAA and Meteor passes for your location
|
||||||
|
- Starts SatDump at the correct time and frequency
|
||||||
|
- Decoded images are saved with timestamps
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
|
||||||
|
3. **View Map** - Vessels appear on the interactive maritime map
|
||||||
|
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
|
||||||
|
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
|
||||||
|
|
||||||
|
### VHF DSC Channel 70
|
||||||
|
|
||||||
|
Digital Selective Calling monitoring runs alongside AIS:
|
||||||
|
- Distress, Urgency, Safety, and Routine messages
|
||||||
|
- Distress positions plotted with pulsing alert markers
|
||||||
|
- Audio alerts for critical messages
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
|
||||||
|
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
|
||||||
|
4. **View Map** - Station positions appear on the interactive map
|
||||||
|
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
|
||||||
|
|
||||||
|
## Utility Meters
|
||||||
|
|
||||||
|
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
|
||||||
|
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
|
||||||
|
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
|
||||||
|
|
||||||
|
## BT Locate (SAR Device Location)
|
||||||
|
|
||||||
|
1. **Set Target** - Enter one or more target identifiers:
|
||||||
|
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
|
||||||
|
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
|
||||||
|
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
|
||||||
|
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
|
||||||
|
2. **Choose Environment** - Select the RF environment preset:
|
||||||
|
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||||
|
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
|
||||||
|
- **Indoor** (n=3.0) - For buildings with walls and obstacles
|
||||||
|
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||||
|
4. **Monitor HUD** - The proximity display shows:
|
||||||
|
- Proximity band (IMMEDIATE / NEAR / FAR)
|
||||||
|
- Estimated distance in meters
|
||||||
|
- Raw RSSI and smoothed RSSI average
|
||||||
|
- Detection count and GPS-tagged points
|
||||||
|
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||||
|
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
|
||||||
|
7. **Review Trail** - Check the map for GPS-tagged detection trail
|
||||||
|
|
||||||
|
### Hand-off from Bluetooth Mode
|
||||||
|
|
||||||
|
1. Open Bluetooth scanning mode and find the target device
|
||||||
|
2. Click the "Locate" button on the device card
|
||||||
|
3. BT Locate opens with the device pre-filled
|
||||||
|
4. Click "Start Locate" to begin tracking
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- For devices with address randomization (iPhones, modern Android), use the IRK method
|
||||||
|
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
|
||||||
|
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
|
||||||
|
- Clear the trail when starting a new search area
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
||||||
|
2. **View Map** - Your position appears on the interactive map with a track trail
|
||||||
|
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
|
||||||
|
4. **Record Track** - Enable track recording to save your path
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
|
||||||
|
- GPS fix may take 30-60 seconds after cold start
|
||||||
|
- Accuracy improves with more satellites in view
|
||||||
|
|
||||||
|
## Meshtastic
|
||||||
|
|
||||||
|
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||||
|
2. **Start** - Click "Start" to connect to the mesh network
|
||||||
|
3. **View Messages** - Real-time message stream from the mesh
|
||||||
|
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
||||||
|
5. **Send Messages** - Type messages to broadcast on the mesh
|
||||||
|
|
||||||
## Remote Agents (Distributed SIGINT)
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
+59
-1
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">15+</span>
|
<span class="stat-value">20+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -77,6 +77,12 @@
|
|||||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📻</div>
|
||||||
|
<h3>Sub-GHz Analyzer</h3>
|
||||||
|
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">📻</div>
|
<div class="feature-icon">📻</div>
|
||||||
<h3>Listening Post</h3>
|
<h3>Listening Post</h3>
|
||||||
@@ -101,6 +107,18 @@
|
|||||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📍</div>
|
||||||
|
<h3>BT Locate</h3>
|
||||||
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🛰️</div>
|
||||||
|
<h3>GPS Tracking</h3>
|
||||||
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">🛡️</div>
|
<div class="feature-icon">🛡️</div>
|
||||||
<h3>TSCM</h3>
|
<h3>TSCM</h3>
|
||||||
@@ -143,11 +161,47 @@
|
|||||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌧️</div>
|
||||||
|
<h3>Weather Satellites</h3>
|
||||||
|
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">🖼️</div>
|
<div class="feature-icon">🖼️</div>
|
||||||
<h3>ISS SSTV</h3>
|
<h3>ISS SSTV</h3>
|
||||||
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🖼️</div>
|
||||||
|
<h3>HF SSTV</h3>
|
||||||
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">✈️</div>
|
||||||
|
<h3>ACARS</h3>
|
||||||
|
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📍</div>
|
||||||
|
<h3>APRS</h3>
|
||||||
|
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌐</div>
|
||||||
|
<h3>WebSDR</h3>
|
||||||
|
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Utility Meters</h3>
|
||||||
|
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -194,6 +248,10 @@
|
|||||||
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||||
<span class="screenshot-label">AIS Vessel Tracking</span>
|
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||||
|
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Download sample NOAA APT recordings for testing the weather satellite
|
||||||
|
# test-decode feature. These are FM-demodulated audio WAV files.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./download-weather-sat-samples.sh
|
||||||
|
# docker exec intercept /app/download-weather-sat-samples.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
|
||||||
|
mkdir -p "$SAMPLE_DIR"
|
||||||
|
|
||||||
|
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
|
||||||
|
|
||||||
|
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
|
||||||
|
# Source: https://github.com/martinber/noaa-apt
|
||||||
|
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
|
||||||
|
echo " -> noaa_apt_argentina.wav (18 MB) ..."
|
||||||
|
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
|
||||||
|
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
|
||||||
|
else
|
||||||
|
echo " -> noaa_apt_argentina.wav (already exists)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Test decode with:"
|
||||||
|
echo " Satellite: NOAA-18"
|
||||||
|
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
|
||||||
|
echo " Sample rate: 11025 Hz"
|
||||||
+199
-199
@@ -838,15 +838,15 @@ class ModeManager:
|
|||||||
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
|
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
|
||||||
elif mode == 'aprs':
|
elif mode == 'aprs':
|
||||||
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
|
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
|
||||||
elif mode == 'tscm':
|
elif mode == 'tscm':
|
||||||
data['data'] = {
|
data['data'] = {
|
||||||
'anomalies': getattr(self, 'tscm_anomalies', []),
|
'anomalies': getattr(self, 'tscm_anomalies', []),
|
||||||
'baseline': getattr(self, 'tscm_baseline', {}),
|
'baseline': getattr(self, 'tscm_baseline', {}),
|
||||||
'wifi_devices': list(self.wifi_networks.values()),
|
'wifi_devices': list(self.wifi_networks.values()),
|
||||||
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
||||||
'bt_devices': list(self.bluetooth_devices.values()),
|
'bt_devices': list(self.bluetooth_devices.values()),
|
||||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
||||||
}
|
}
|
||||||
elif mode == 'listening_post':
|
elif mode == 'listening_post':
|
||||||
data['data'] = {
|
data['data'] = {
|
||||||
'activity': getattr(self, 'listening_post_activity', []),
|
'activity': getattr(self, 'listening_post_activity', []),
|
||||||
@@ -1105,24 +1105,24 @@ class ModeManager:
|
|||||||
self.wifi_clients.clear()
|
self.wifi_clients.clear()
|
||||||
elif mode == 'bluetooth':
|
elif mode == 'bluetooth':
|
||||||
self.bluetooth_devices.clear()
|
self.bluetooth_devices.clear()
|
||||||
elif mode == 'tscm':
|
elif mode == 'tscm':
|
||||||
# Clean up TSCM sub-threads
|
# Clean up TSCM sub-threads
|
||||||
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
|
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
|
||||||
if sub_thread_name in self.output_threads:
|
if sub_thread_name in self.output_threads:
|
||||||
thread = self.output_threads[sub_thread_name]
|
thread = self.output_threads[sub_thread_name]
|
||||||
if thread and thread.is_alive():
|
if thread and thread.is_alive():
|
||||||
thread.join(timeout=2)
|
thread.join(timeout=2)
|
||||||
del self.output_threads[sub_thread_name]
|
del self.output_threads[sub_thread_name]
|
||||||
# Clear TSCM data
|
# Clear TSCM data
|
||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
self.tscm_baseline = {}
|
self.tscm_baseline = {}
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
self.tscm_wifi_clients = {}
|
self.tscm_wifi_clients = {}
|
||||||
# Clear reported threat tracking sets
|
# Clear reported threat tracking sets
|
||||||
if hasattr(self, '_tscm_reported_wifi'):
|
if hasattr(self, '_tscm_reported_wifi'):
|
||||||
self._tscm_reported_wifi.clear()
|
self._tscm_reported_wifi.clear()
|
||||||
if hasattr(self, '_tscm_reported_bt'):
|
if hasattr(self, '_tscm_reported_bt'):
|
||||||
self._tscm_reported_bt.clear()
|
self._tscm_reported_bt.clear()
|
||||||
elif mode == 'dsc':
|
elif mode == 'dsc':
|
||||||
# Clear DSC data
|
# Clear DSC data
|
||||||
if hasattr(self, 'dsc_messages'):
|
if hasattr(self, 'dsc_messages'):
|
||||||
@@ -1542,10 +1542,10 @@ class ModeManager:
|
|||||||
def _start_wifi(self, params: dict) -> dict:
|
def _start_wifi(self, params: dict) -> dict:
|
||||||
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
||||||
interface = params.get('interface')
|
interface = params.get('interface')
|
||||||
channel = params.get('channel')
|
channel = params.get('channel')
|
||||||
channels = params.get('channels')
|
channels = params.get('channels')
|
||||||
band = params.get('band', 'abg')
|
band = params.get('band', 'abg')
|
||||||
scan_type = params.get('scan_type', 'deep')
|
scan_type = params.get('scan_type', 'deep')
|
||||||
|
|
||||||
# Handle quick scan - returns results synchronously
|
# Handle quick scan - returns results synchronously
|
||||||
if scan_type == 'quick':
|
if scan_type == 'quick':
|
||||||
@@ -1574,21 +1574,21 @@ class ModeManager:
|
|||||||
else:
|
else:
|
||||||
scan_band = 'all'
|
scan_band = 'all'
|
||||||
|
|
||||||
channel_list = None
|
channel_list = None
|
||||||
if channels:
|
if channels:
|
||||||
if isinstance(channels, str):
|
if isinstance(channels, str):
|
||||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
elif isinstance(channels, (list, tuple, set)):
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
channel_list = list(channels)
|
channel_list = list(channels)
|
||||||
else:
|
else:
|
||||||
channel_list = [channels]
|
channel_list = [channels]
|
||||||
try:
|
try:
|
||||||
channel_list = [int(c) for c in channel_list]
|
channel_list = [int(c) for c in channel_list]
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return {'status': 'error', 'message': 'Invalid channels'}
|
return {'status': 'error', 'message': 'Invalid channels'}
|
||||||
|
|
||||||
# Start deep scan
|
# Start deep scan
|
||||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
||||||
# Start thread to sync data to agent's dictionaries
|
# Start thread to sync data to agent's dictionaries
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._wifi_data_sync,
|
target=self._wifi_data_sync,
|
||||||
@@ -1607,12 +1607,12 @@ class ModeManager:
|
|||||||
else:
|
else:
|
||||||
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
|
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback to direct airodump-ng
|
# Fallback to direct airodump-ng
|
||||||
return self._start_wifi_fallback(interface, channel, band, channels)
|
return self._start_wifi_fallback(interface, channel, band, channels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WiFi scanner error: {e}")
|
logger.error(f"WiFi scanner error: {e}")
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
def _wifi_data_sync(self, scanner):
|
def _wifi_data_sync(self, scanner):
|
||||||
"""Sync WiFi scanner data to agent's data structures."""
|
"""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:
|
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
||||||
self._wifi_scanner_instance.stop_deep_scan()
|
self._wifi_scanner_instance.stop_deep_scan()
|
||||||
|
|
||||||
def _start_wifi_fallback(
|
def _start_wifi_fallback(
|
||||||
self,
|
self,
|
||||||
interface: str | None,
|
interface: str | None,
|
||||||
channel: int | None,
|
channel: int | None,
|
||||||
band: str,
|
band: str,
|
||||||
channels: list[int] | str | None = None,
|
channels: list[int] | str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
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]
|
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
||||||
if gps_manager.is_running:
|
if gps_manager.is_running:
|
||||||
cmd.append('--gpsd')
|
cmd.append('--gpsd')
|
||||||
channel_list = None
|
channel_list = None
|
||||||
if channels:
|
if channels:
|
||||||
if isinstance(channels, str):
|
if isinstance(channels, str):
|
||||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
elif isinstance(channels, (list, tuple, set)):
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
channel_list = list(channels)
|
channel_list = list(channels)
|
||||||
else:
|
else:
|
||||||
channel_list = [channels]
|
channel_list = [channels]
|
||||||
try:
|
try:
|
||||||
channel_list = [int(c) for c in channel_list]
|
channel_list = [int(c) for c in channel_list]
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return {'status': 'error', 'message': 'Invalid channels'}
|
return {'status': 'error', 'message': 'Invalid channels'}
|
||||||
|
|
||||||
if channel_list:
|
if channel_list:
|
||||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||||
elif channel:
|
elif channel:
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
cmd.append(interface)
|
cmd.append(interface)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2022,7 +2022,7 @@ class ModeManager:
|
|||||||
'agent_gps': gps_manager.position
|
'agent_gps': gps_manager.position
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner.set_on_device_updated(on_device_updated)
|
scanner.add_device_callback(on_device_updated)
|
||||||
|
|
||||||
# Start scanning
|
# Start scanning
|
||||||
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
||||||
@@ -3148,21 +3148,21 @@ class ModeManager:
|
|||||||
self.tscm_baseline = {}
|
self.tscm_baseline = {}
|
||||||
if not hasattr(self, 'tscm_anomalies'):
|
if not hasattr(self, 'tscm_anomalies'):
|
||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
if not hasattr(self, 'tscm_rf_signals'):
|
if not hasattr(self, 'tscm_rf_signals'):
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
if not hasattr(self, 'tscm_wifi_clients'):
|
if not hasattr(self, 'tscm_wifi_clients'):
|
||||||
self.tscm_wifi_clients = {}
|
self.tscm_wifi_clients = {}
|
||||||
self.tscm_anomalies.clear()
|
self.tscm_anomalies.clear()
|
||||||
self.tscm_wifi_clients.clear()
|
self.tscm_wifi_clients.clear()
|
||||||
|
|
||||||
# Get params for what to scan
|
# Get params for what to scan
|
||||||
scan_wifi = params.get('wifi', True)
|
scan_wifi = params.get('wifi', True)
|
||||||
scan_bt = params.get('bluetooth', True)
|
scan_bt = params.get('bluetooth', True)
|
||||||
scan_rf = params.get('rf', True)
|
scan_rf = params.get('rf', True)
|
||||||
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
||||||
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
||||||
sdr_device = params.get('sdr_device', params.get('device', 0))
|
sdr_device = params.get('sdr_device', params.get('device', 0))
|
||||||
sweep_type = params.get('sweep_type')
|
sweep_type = params.get('sweep_type')
|
||||||
|
|
||||||
# Get baseline_id for comparison (same as local mode)
|
# Get baseline_id for comparison (same as local mode)
|
||||||
baseline_id = params.get('baseline_id')
|
baseline_id = params.get('baseline_id')
|
||||||
@@ -3170,11 +3170,11 @@ class ModeManager:
|
|||||||
started_scans = []
|
started_scans = []
|
||||||
|
|
||||||
# Start the combined TSCM scanner thread using existing Intercept functions
|
# Start the combined TSCM scanner thread using existing Intercept functions
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._tscm_scanner_thread,
|
target=self._tscm_scanner_thread,
|
||||||
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
|
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
self.output_threads['tscm'] = thread
|
self.output_threads['tscm'] = thread
|
||||||
|
|
||||||
@@ -3193,9 +3193,9 @@ class ModeManager:
|
|||||||
'scanning': started_scans
|
'scanning': started_scans
|
||||||
}
|
}
|
||||||
|
|
||||||
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
||||||
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
||||||
baseline_id: int | None = None, sweep_type: str | None = None):
|
baseline_id: int | None = None, sweep_type: str | None = None):
|
||||||
"""Combined TSCM scanner using existing Intercept functions.
|
"""Combined TSCM scanner using existing Intercept functions.
|
||||||
|
|
||||||
NOTE: This matches local mode behavior exactly:
|
NOTE: This matches local mode behavior exactly:
|
||||||
@@ -3208,20 +3208,20 @@ class ModeManager:
|
|||||||
stop_event = self.stop_events.get(mode)
|
stop_event = self.stop_events.get(mode)
|
||||||
|
|
||||||
# Import existing Intercept TSCM functions
|
# Import existing Intercept TSCM functions
|
||||||
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
||||||
logger.info("TSCM imports successful")
|
logger.info("TSCM imports successful")
|
||||||
|
|
||||||
sweep_ranges = None
|
sweep_ranges = None
|
||||||
if sweep_type:
|
if sweep_type:
|
||||||
try:
|
try:
|
||||||
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
||||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||||
sweep_ranges = preset.get('ranges') if preset else None
|
sweep_ranges = preset.get('ranges') if preset else None
|
||||||
except Exception:
|
except Exception:
|
||||||
sweep_ranges = None
|
sweep_ranges = None
|
||||||
|
|
||||||
# Load baseline if specified (same as local mode)
|
# Load baseline if specified (same as local mode)
|
||||||
baseline = None
|
baseline = None
|
||||||
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
||||||
baseline = get_tscm_baseline(baseline_id)
|
baseline = get_tscm_baseline(baseline_id)
|
||||||
if baseline:
|
if baseline:
|
||||||
@@ -3242,9 +3242,9 @@ class ModeManager:
|
|||||||
self._tscm_correlation = None
|
self._tscm_correlation = None
|
||||||
|
|
||||||
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
||||||
seen_wifi = {}
|
seen_wifi = {}
|
||||||
seen_wifi_clients = {}
|
seen_wifi_clients = {}
|
||||||
seen_bt = {}
|
seen_bt = {}
|
||||||
|
|
||||||
last_rf_scan = 0
|
last_rf_scan = 0
|
||||||
rf_scan_interval = 30
|
rf_scan_interval = 30
|
||||||
@@ -3290,63 +3290,63 @@ class ModeManager:
|
|||||||
enriched['is_new'] = not classification.get('in_baseline', False)
|
enriched['is_new'] = not classification.get('in_baseline', False)
|
||||||
enriched['reasons'] = classification.get('reasons', [])
|
enriched['reasons'] = classification.get('reasons', [])
|
||||||
|
|
||||||
if self._tscm_correlation:
|
if self._tscm_correlation:
|
||||||
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
||||||
enriched['classification'] = profile.risk_level.value
|
enriched['classification'] = profile.risk_level.value
|
||||||
enriched['score'] = profile.total_score
|
enriched['score'] = profile.total_score
|
||||||
enriched['score_modifier'] = profile.score_modifier
|
enriched['score_modifier'] = profile.score_modifier
|
||||||
enriched['known_device'] = profile.known_device
|
enriched['known_device'] = profile.known_device
|
||||||
enriched['known_device_name'] = profile.known_device_name
|
enriched['known_device_name'] = profile.known_device_name
|
||||||
enriched['indicators'] = [
|
enriched['indicators'] = [
|
||||||
{'type': i.type.value, 'desc': i.description}
|
{'type': i.type.value, 'desc': i.description}
|
||||||
for i in profile.indicators
|
for i in profile.indicators
|
||||||
]
|
]
|
||||||
enriched['recommended_action'] = profile.recommended_action
|
enriched['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
self.wifi_networks[bssid] = enriched
|
self.wifi_networks[bssid] = enriched
|
||||||
|
|
||||||
# WiFi clients (monitor mode only)
|
# WiFi clients (monitor mode only)
|
||||||
try:
|
try:
|
||||||
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
||||||
for client in wifi_clients:
|
for client in wifi_clients:
|
||||||
mac = (client.get('mac') or '').upper()
|
mac = (client.get('mac') or '').upper()
|
||||||
if not mac or mac in seen_wifi_clients:
|
if not mac or mac in seen_wifi_clients:
|
||||||
continue
|
continue
|
||||||
seen_wifi_clients[mac] = client
|
seen_wifi_clients[mac] = client
|
||||||
|
|
||||||
rssi_val = client.get('rssi_current')
|
rssi_val = client.get('rssi_current')
|
||||||
if rssi_val is None:
|
if rssi_val is None:
|
||||||
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
||||||
|
|
||||||
client_device = {
|
client_device = {
|
||||||
'mac': mac,
|
'mac': mac,
|
||||||
'vendor': client.get('vendor'),
|
'vendor': client.get('vendor'),
|
||||||
'name': client.get('vendor') or 'WiFi Client',
|
'name': client.get('vendor') or 'WiFi Client',
|
||||||
'rssi': rssi_val,
|
'rssi': rssi_val,
|
||||||
'associated_bssid': client.get('associated_bssid'),
|
'associated_bssid': client.get('associated_bssid'),
|
||||||
'probed_ssids': client.get('probed_ssids', []),
|
'probed_ssids': client.get('probed_ssids', []),
|
||||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||||
'is_client': True,
|
'is_client': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
if self._tscm_correlation:
|
if self._tscm_correlation:
|
||||||
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
||||||
client_device['classification'] = profile.risk_level.value
|
client_device['classification'] = profile.risk_level.value
|
||||||
client_device['score'] = profile.total_score
|
client_device['score'] = profile.total_score
|
||||||
client_device['score_modifier'] = profile.score_modifier
|
client_device['score_modifier'] = profile.score_modifier
|
||||||
client_device['known_device'] = profile.known_device
|
client_device['known_device'] = profile.known_device
|
||||||
client_device['known_device_name'] = profile.known_device_name
|
client_device['known_device_name'] = profile.known_device_name
|
||||||
client_device['indicators'] = [
|
client_device['indicators'] = [
|
||||||
{'type': i.type.value, 'desc': i.description}
|
{'type': i.type.value, 'desc': i.description}
|
||||||
for i in profile.indicators
|
for i in profile.indicators
|
||||||
]
|
]
|
||||||
client_device['recommended_action'] = profile.recommended_action
|
client_device['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
self.tscm_wifi_clients[mac] = client_device
|
self.tscm_wifi_clients[mac] = client_device
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WiFi client scan error: {e}")
|
logger.debug(f"WiFi client scan error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WiFi scan error: {e}")
|
logger.debug(f"WiFi scan error: {e}")
|
||||||
|
|
||||||
# Bluetooth scan using Intercept's function (same as local mode)
|
# Bluetooth scan using Intercept's function (same as local mode)
|
||||||
if scan_bt:
|
if scan_bt:
|
||||||
@@ -3380,18 +3380,18 @@ class ModeManager:
|
|||||||
enriched['is_new'] = not classification.get('in_baseline', False)
|
enriched['is_new'] = not classification.get('in_baseline', False)
|
||||||
enriched['reasons'] = classification.get('reasons', [])
|
enriched['reasons'] = classification.get('reasons', [])
|
||||||
|
|
||||||
if self._tscm_correlation:
|
if self._tscm_correlation:
|
||||||
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
||||||
enriched['classification'] = profile.risk_level.value
|
enriched['classification'] = profile.risk_level.value
|
||||||
enriched['score'] = profile.total_score
|
enriched['score'] = profile.total_score
|
||||||
enriched['score_modifier'] = profile.score_modifier
|
enriched['score_modifier'] = profile.score_modifier
|
||||||
enriched['known_device'] = profile.known_device
|
enriched['known_device'] = profile.known_device
|
||||||
enriched['known_device_name'] = profile.known_device_name
|
enriched['known_device_name'] = profile.known_device_name
|
||||||
enriched['indicators'] = [
|
enriched['indicators'] = [
|
||||||
{'type': i.type.value, 'desc': i.description}
|
{'type': i.type.value, 'desc': i.description}
|
||||||
for i in profile.indicators
|
for i in profile.indicators
|
||||||
]
|
]
|
||||||
enriched['recommended_action'] = profile.recommended_action
|
enriched['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
self.bluetooth_devices[mac] = enriched
|
self.bluetooth_devices[mac] = enriched
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -3402,11 +3402,11 @@ class ModeManager:
|
|||||||
try:
|
try:
|
||||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
# 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()
|
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
||||||
rf_signals = _scan_rf_signals(
|
rf_signals = _scan_rf_signals(
|
||||||
sdr_device,
|
sdr_device,
|
||||||
stop_check=agent_stop_check,
|
stop_check=agent_stop_check,
|
||||||
sweep_ranges=sweep_ranges
|
sweep_ranges=sweep_ranges
|
||||||
)
|
)
|
||||||
|
|
||||||
# Analyze each RF signal like local mode does
|
# Analyze each RF signal like local mode does
|
||||||
analyzed_signals = []
|
analyzed_signals = []
|
||||||
@@ -3426,17 +3426,17 @@ class ModeManager:
|
|||||||
analyzed['reasons'] = classification.get('reasons', [])
|
analyzed['reasons'] = classification.get('reasons', [])
|
||||||
|
|
||||||
# Use correlation engine for scoring (same as local mode)
|
# Use correlation engine for scoring (same as local mode)
|
||||||
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
|
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
|
||||||
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
||||||
analyzed['classification'] = profile.risk_level.value
|
analyzed['classification'] = profile.risk_level.value
|
||||||
analyzed['score'] = profile.total_score
|
analyzed['score'] = profile.total_score
|
||||||
analyzed['score_modifier'] = profile.score_modifier
|
analyzed['score_modifier'] = profile.score_modifier
|
||||||
analyzed['known_device'] = profile.known_device
|
analyzed['known_device'] = profile.known_device
|
||||||
analyzed['known_device_name'] = profile.known_device_name
|
analyzed['known_device_name'] = profile.known_device_name
|
||||||
analyzed['indicators'] = [
|
analyzed['indicators'] = [
|
||||||
{'type': i.type.value, 'desc': i.description}
|
{'type': i.type.value, 'desc': i.description}
|
||||||
for i in profile.indicators
|
for i in profile.indicators
|
||||||
]
|
]
|
||||||
|
|
||||||
analyzed['is_threat'] = is_threat
|
analyzed['is_threat'] = is_threat
|
||||||
analyzed_signals.append(analyzed)
|
analyzed_signals.append(analyzed)
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.15.0"
|
version = "2.18.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = {text = "MIT"}
|
license = {text = "Apache-2.0"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Intercept Contributors"}
|
{name = "Intercept Contributors"}
|
||||||
]
|
]
|
||||||
@@ -14,7 +14,7 @@ classifiers = [
|
|||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Framework :: Flask",
|
"Framework :: Flask",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Operating System :: MacOS",
|
"Operating System :: MacOS",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ scapy>=2.4.5
|
|||||||
# QR code generation for Meshtastic channels (optional)
|
# QR code generation for Meshtastic channels (optional)
|
||||||
qrcode[pil]>=7.4
|
qrcode[pil]>=7.4
|
||||||
|
|
||||||
|
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
|
||||||
|
cryptography>=41.0.0
|
||||||
|
|
||||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||||
# pytest>=7.0.0
|
# pytest>=7.0.0
|
||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ def register_blueprints(app):
|
|||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
from .updater import updater_bp
|
from .updater import updater_bp
|
||||||
from .sstv import sstv_bp
|
from .sstv import sstv_bp
|
||||||
|
from .weather_sat import weather_sat_bp
|
||||||
from .sstv_general import sstv_general_bp
|
from .sstv_general import sstv_general_bp
|
||||||
from .dmr import dmr_bp
|
from .dmr import dmr_bp
|
||||||
from .websdr import websdr_bp
|
from .websdr import websdr_bp
|
||||||
from .alerts import alerts_bp
|
from .alerts import alerts_bp
|
||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
|
from .subghz import subghz_bp
|
||||||
|
from .bt_locate import bt_locate_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -56,11 +59,14 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(offline_bp) # Offline mode settings
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
app.register_blueprint(updater_bp) # GitHub update checking
|
app.register_blueprint(updater_bp) # GitHub update checking
|
||||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
|
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||||
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
+37
-14
@@ -20,8 +20,9 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.event_pipeline import process_event
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -250,12 +251,22 @@ def start_acars() -> Response:
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
# Build acarsdec command
|
# Build acarsdec command
|
||||||
# Different forks have different syntax:
|
# Different forks have different syntax:
|
||||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
# Note: gain/ppm must come BEFORE -r
|
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
|
||||||
|
# Note: gain/ppm must come BEFORE -r/-d
|
||||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
cmd = [acarsdec_path]
|
cmd = [acarsdec_path]
|
||||||
if json_flag == '--output':
|
if json_flag == '--output':
|
||||||
@@ -266,21 +277,33 @@ def start_acars() -> Response:
|
|||||||
else:
|
else:
|
||||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||||
|
|
||||||
# Add gain if not auto (must be before -r)
|
# Add gain if not auto (must be before -r/-d)
|
||||||
if gain and str(gain) != '0':
|
if gain and str(gain) != '0':
|
||||||
cmd.extend(['-g', str(gain)])
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
# Add PPM correction if specified (must be before -r)
|
# Add PPM correction if specified (must be before -r/-d)
|
||||||
if ppm and str(ppm) != '0':
|
if ppm and str(ppm) != '0':
|
||||||
cmd.extend(['-p', str(ppm)])
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Add device and frequencies
|
# Add device and frequencies
|
||||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
if is_soapy:
|
||||||
if json_flag == '--output':
|
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||||
|
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device_str = builder._build_device_string(sdr_device)
|
||||||
|
if json_flag == '--output':
|
||||||
|
cmd.extend(['-m', '256'])
|
||||||
|
cmd.extend(['--soapysdr', device_str])
|
||||||
|
else:
|
||||||
|
cmd.extend(['-d', device_str])
|
||||||
|
elif json_flag == '--output':
|
||||||
|
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
|
||||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
cmd.extend(['-m', '256'])
|
cmd.extend(['-m', '256'])
|
||||||
cmd.extend(['--rtlsdr', str(device)])
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
else:
|
else:
|
||||||
|
# TLeconte fork RTL-SDR: -r <device>
|
||||||
cmd.extend(['-r', str(device)])
|
cmd.extend(['-r', str(device)])
|
||||||
cmd.extend(frequencies)
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
@@ -392,13 +415,13 @@ def stream_acars() -> Response:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('acars', msg, msg.get('type'))
|
process_event('acars', msg, msg.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from config import (
|
|||||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
)
|
)
|
||||||
from utils.logging import adsb_logger as logger
|
from utils.logging import adsb_logger as logger
|
||||||
|
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index, validate_gain,
|
validate_device_index, validate_gain,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
@@ -633,6 +634,9 @@ def start_adsb():
|
|||||||
'session': session
|
'session': session
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Kill any stale app-spawned dump1090 from a previous run before checking the port
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
|
||||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||||
existing_service = check_dump1090_service()
|
existing_service = check_dump1090_service()
|
||||||
if existing_service:
|
if existing_service:
|
||||||
@@ -685,6 +689,7 @@ def start_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("Killed stale ADS-B process")
|
logger.info("Killed stale ADS-B process")
|
||||||
|
|
||||||
# Check if device is available before starting local dump1090
|
# Check if device is available before starting local dump1090
|
||||||
@@ -721,6 +726,7 @@ def start_adsb():
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
start_new_session=True # Create new process group for clean shutdown
|
start_new_session=True # Create new process group for clean shutdown
|
||||||
)
|
)
|
||||||
|
write_dump1090_pid(app_module.adsb_process.pid)
|
||||||
|
|
||||||
time.sleep(DUMP1090_START_WAIT)
|
time.sleep(DUMP1090_START_WAIT)
|
||||||
|
|
||||||
@@ -819,6 +825,7 @@ def stop_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("ADS-B process stopped")
|
logger.info("ADS-B process stopped")
|
||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
|
|||||||
+101
-79
@@ -19,15 +19,16 @@ from typing import Generator, Optional
|
|||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.sdr import SDRFactory, SDRType
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
from utils.constants import (
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
PROCESS_START_WAIT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||||
@@ -72,14 +73,19 @@ def find_multimon_ng() -> Optional[str]:
|
|||||||
return shutil.which('multimon-ng')
|
return shutil.which('multimon-ng')
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_fm() -> Optional[str]:
|
def find_rtl_fm() -> Optional[str]:
|
||||||
"""Find rtl_fm binary."""
|
"""Find rtl_fm binary."""
|
||||||
return shutil.which('rtl_fm')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_power() -> Optional[str]:
|
def find_rx_fm() -> Optional[str]:
|
||||||
"""Find rtl_power binary for spectrum scanning."""
|
"""Find SoapySDR rx_fm binary."""
|
||||||
return shutil.which('rtl_power')
|
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
|
# Path to direwolf config file
|
||||||
@@ -1414,19 +1420,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/tools')
|
@aprs_bp.route('/tools')
|
||||||
def check_aprs_tools() -> Response:
|
def check_aprs_tools() -> Response:
|
||||||
"""Check for APRS decoding tools."""
|
"""Check for APRS decoding tools."""
|
||||||
has_rtl_fm = find_rtl_fm() is not None
|
has_rtl_fm = find_rtl_fm() is not None
|
||||||
has_direwolf = find_direwolf() is not None
|
has_rx_fm = find_rx_fm() is not None
|
||||||
has_multimon = find_multimon_ng() is not None
|
has_direwolf = find_direwolf() is not None
|
||||||
|
has_multimon = find_multimon_ng() is not None
|
||||||
return jsonify({
|
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||||
'rtl_fm': has_rtl_fm,
|
|
||||||
'direwolf': has_direwolf,
|
return jsonify({
|
||||||
'multimon_ng': has_multimon,
|
'rtl_fm': has_rtl_fm,
|
||||||
'ready': has_rtl_fm and (has_direwolf or has_multimon),
|
'rx_fm': has_rx_fm,
|
||||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
'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')
|
@aprs_bp.route('/status')
|
||||||
@@ -1467,20 +1476,12 @@ def start_aprs() -> Response:
|
|||||||
'message': 'APRS decoder already running'
|
'message': 'APRS decoder already running'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
# Check for required tools
|
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||||
rtl_fm_path = find_rtl_fm()
|
direwolf_path = find_direwolf()
|
||||||
if not rtl_fm_path:
|
multimon_path = find_multimon_ng()
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
if not direwolf_path and not multimon_path:
|
||||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
return jsonify({
|
||||||
}), 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({
|
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||||
}), 400
|
}), 400
|
||||||
@@ -1488,12 +1489,31 @@ def start_aprs() -> Response:
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
try:
|
try:
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
gain = validate_gain(data.get('gain', '40'))
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
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
|
# Reserve SDR device to prevent conflicts with other modes
|
||||||
error = app_module.claim_sdr_device(device, 'aprs')
|
error = app_module.claim_sdr_device(device, 'aprs')
|
||||||
@@ -1525,28 +1545,29 @@ def start_aprs() -> Response:
|
|||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {}
|
aprs_stations = {}
|
||||||
|
|
||||||
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
|
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||||
freq_hz = f"{float(frequency)}M"
|
try:
|
||||||
rtl_cmd = [
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
rtl_fm_path,
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
'-f', freq_hz,
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
'-M', 'nfm', # Narrowband FM for APRS
|
device=sdr_device,
|
||||||
'-s', '22050', # Sample rate matching direwolf -r 22050
|
frequency_mhz=float(frequency),
|
||||||
'-E', 'dc', # Enable DC blocking filter for cleaner audio
|
sample_rate=22050,
|
||||||
'-A', 'fast', # Fast AGC for packet bursts
|
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||||
'-d', str(device),
|
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||||
]
|
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||||
|
squelch=None,
|
||||||
# Gain: 0 means auto, otherwise set specific gain
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
if gain and str(gain) != '0':
|
)
|
||||||
rtl_cmd.extend(['-g', str(gain)])
|
|
||||||
|
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||||
# PPM frequency correction
|
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||||
if ppm and str(ppm) != '0':
|
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||||
rtl_cmd.extend(['-p', str(ppm)])
|
except Exception as e:
|
||||||
|
if aprs_active_device is not None:
|
||||||
# Output raw audio to stdout
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
rtl_cmd.append('-')
|
aprs_active_device = None
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||||
|
|
||||||
# Build decoder command
|
# Build decoder command
|
||||||
if direwolf_path:
|
if direwolf_path:
|
||||||
@@ -1669,13 +1690,14 @@ def start_aprs() -> Response:
|
|||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'region': region,
|
'region': region,
|
||||||
'device': device,
|
'device': device,
|
||||||
'decoder': decoder_name
|
'sdr_type': sdr_type.value,
|
||||||
})
|
'decoder': decoder_name
|
||||||
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start APRS decoder: {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')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_ffmpeg():
|
def find_ffmpeg():
|
||||||
return shutil.which('ffmpeg')
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
def kill_audio_processes():
|
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."""
|
"""Kill any running audio processes."""
|
||||||
global audio_process, rtl_process
|
global audio_process, rtl_process
|
||||||
|
|
||||||
@@ -104,14 +110,14 @@ def start_audio_stream(config):
|
|||||||
|
|
||||||
freq_hz = int(freq * 1e6)
|
freq_hz = int(freq * 1e6)
|
||||||
|
|
||||||
rtl_cmd = [
|
rtl_cmd = [
|
||||||
rtl_fm,
|
rtl_fm,
|
||||||
'-M', mod,
|
'-M', _rtl_fm_demod_mode(mod),
|
||||||
'-f', str(freq_hz),
|
'-f', str(freq_hz),
|
||||||
'-s', str(sample_rate),
|
'-s', str(sample_rate),
|
||||||
'-r', str(resample_rate),
|
'-r', str(resample_rate),
|
||||||
'-g', str(gain),
|
'-g', str(gain),
|
||||||
'-d', str(device),
|
'-d', str(device),
|
||||||
'-l', str(squelch),
|
'-l', str(squelch),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+178
-178
@@ -7,40 +7,40 @@ aggregation, and heuristics.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, session
|
from flask import Blueprint, Response, jsonify, request, session
|
||||||
|
|
||||||
from utils.bluetooth import (
|
from utils.bluetooth import (
|
||||||
BluetoothScanner,
|
BluetoothScanner,
|
||||||
BTDeviceAggregate,
|
BTDeviceAggregate,
|
||||||
get_bluetooth_scanner,
|
get_bluetooth_scanner,
|
||||||
check_capabilities,
|
check_capabilities,
|
||||||
RANGE_UNKNOWN,
|
RANGE_UNKNOWN,
|
||||||
TrackerType,
|
TrackerType,
|
||||||
TrackerConfidence,
|
TrackerConfidence,
|
||||||
get_tracker_engine,
|
get_tracker_engine,
|
||||||
)
|
)
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
# Blueprint
|
# Blueprint
|
||||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||||
|
|
||||||
# Seen-before tracking
|
# Seen-before tracking
|
||||||
_bt_seen_cache: set[str] = set()
|
_bt_seen_cache: set[str] = set()
|
||||||
_bt_session_seen: set[str] = set()
|
_bt_session_seen: set[str] = set()
|
||||||
_bt_seen_lock = threading.Lock()
|
_bt_seen_lock = threading.Lock()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE FUNCTIONS
|
# DATABASE FUNCTIONS
|
||||||
@@ -172,20 +172,20 @@ def get_all_baselines() -> list[dict]:
|
|||||||
return [dict(row) for row in cursor]
|
return [dict(row) for row in cursor]
|
||||||
|
|
||||||
|
|
||||||
def save_observation_history(device: BTDeviceAggregate) -> None:
|
def save_observation_history(device: BTDeviceAggregate) -> None:
|
||||||
"""Save device observation to history."""
|
"""Save device observation to history."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
|
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||||
|
|
||||||
|
|
||||||
def load_seen_device_ids() -> set[str]:
|
def load_seen_device_ids() -> set[str]:
|
||||||
"""Load distinct device IDs from history for seen-before tracking."""
|
"""Load distinct device IDs from history for seen-before tracking."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
||||||
return {row['device_id'] for row in cursor}
|
return {row['device_id'] for row in cursor}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -206,7 +206,7 @@ def get_capabilities():
|
|||||||
|
|
||||||
|
|
||||||
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
|
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
|
||||||
def start_scan():
|
def start_scan():
|
||||||
"""
|
"""
|
||||||
Start Bluetooth scanning.
|
Start Bluetooth scanning.
|
||||||
|
|
||||||
@@ -229,49 +229,49 @@ def start_scan():
|
|||||||
rssi_threshold = data.get('rssi_threshold', -100)
|
rssi_threshold = data.get('rssi_threshold', -100)
|
||||||
|
|
||||||
# Validate mode
|
# Validate mode
|
||||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||||
if mode not in valid_modes:
|
if mode not in valid_modes:
|
||||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||||
|
|
||||||
# Get scanner instance
|
# Get scanner instance
|
||||||
scanner = get_bluetooth_scanner(adapter_id)
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
|
|
||||||
# Initialize database tables if needed
|
# Initialize database tables if needed
|
||||||
init_bt_tables()
|
init_bt_tables()
|
||||||
|
|
||||||
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
||||||
try:
|
try:
|
||||||
with _bt_seen_lock:
|
with _bt_seen_lock:
|
||||||
device.seen_before = device.device_id in _bt_seen_cache
|
device.seen_before = device.device_id in _bt_seen_cache
|
||||||
if device.device_id not in _bt_session_seen:
|
if device.device_id not in _bt_session_seen:
|
||||||
save_observation_history(device)
|
save_observation_history(device)
|
||||||
_bt_session_seen.add(device.device_id)
|
_bt_session_seen.add(device.device_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"BT seen-before update failed: {e}")
|
logger.debug(f"BT seen-before update failed: {e}")
|
||||||
|
|
||||||
# Setup seen-before callback
|
# Setup seen-before callback
|
||||||
if scanner._on_device_updated is None:
|
if _handle_seen_before not in scanner._on_device_updated_callbacks:
|
||||||
scanner._on_device_updated = _handle_seen_before
|
scanner.add_device_callback(_handle_seen_before)
|
||||||
|
|
||||||
# Ensure cache is initialized
|
# Ensure cache is initialized
|
||||||
with _bt_seen_lock:
|
with _bt_seen_lock:
|
||||||
if not _bt_seen_cache:
|
if not _bt_seen_cache:
|
||||||
_bt_seen_cache.update(load_seen_device_ids())
|
_bt_seen_cache.update(load_seen_device_ids())
|
||||||
|
|
||||||
# Check if already scanning
|
# Check if already scanning
|
||||||
if scanner.is_scanning:
|
if scanner.is_scanning:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'scan_status': scanner.get_status().to_dict()
|
'scan_status': scanner.get_status().to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Refresh seen-before cache and reset session set for a new scan
|
# Refresh seen-before cache and reset session set for a new scan
|
||||||
with _bt_seen_lock:
|
with _bt_seen_lock:
|
||||||
_bt_seen_cache.clear()
|
_bt_seen_cache.clear()
|
||||||
_bt_seen_cache.update(load_seen_device_ids())
|
_bt_seen_cache.update(load_seen_device_ids())
|
||||||
_bt_session_seen.clear()
|
_bt_session_seen.clear()
|
||||||
|
|
||||||
# Load active baseline if exists
|
# Load active baseline if exists
|
||||||
baseline_id = get_active_baseline_id()
|
baseline_id = get_active_baseline_id()
|
||||||
if baseline_id:
|
if baseline_id:
|
||||||
device_ids = get_baseline_device_ids(baseline_id)
|
device_ids = get_baseline_device_ids(baseline_id)
|
||||||
@@ -896,15 +896,15 @@ def stream_events():
|
|||||||
else:
|
else:
|
||||||
return event_type, event
|
return event_type, event
|
||||||
|
|
||||||
def event_generator() -> Generator[str, None, None]:
|
def event_generator() -> Generator[str, None, None]:
|
||||||
"""Generate SSE events from scanner."""
|
"""Generate SSE events from scanner."""
|
||||||
for event in scanner.stream_events(timeout=1.0):
|
for event in scanner.stream_events(timeout=1.0):
|
||||||
event_name, event_data = map_event_type(event)
|
event_name, event_data = map_event_type(event)
|
||||||
try:
|
try:
|
||||||
process_event('bluetooth', event_data, event_name)
|
process_event('bluetooth', event_data, event_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(event_data, event=event_name)
|
yield format_sse(event_data, event=event_name)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
event_generator(),
|
event_generator(),
|
||||||
@@ -988,34 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
devices = scanner.get_devices()
|
devices = scanner.get_devices()
|
||||||
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
|
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
|
||||||
|
|
||||||
# Convert to TSCM format with tracker detection data
|
# Convert to TSCM format with tracker detection data
|
||||||
tscm_devices = []
|
tscm_devices = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
manufacturer_name = device.manufacturer_name
|
manufacturer_name = device.manufacturer_name
|
||||||
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
||||||
if device.address and not device.is_randomized_mac:
|
if device.address and not device.is_randomized_mac:
|
||||||
try:
|
try:
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
oui_vendor = get_manufacturer(device.address)
|
oui_vendor = get_manufacturer(device.address)
|
||||||
if oui_vendor and oui_vendor != 'Unknown':
|
if oui_vendor and oui_vendor != 'Unknown':
|
||||||
manufacturer_name = oui_vendor
|
manufacturer_name = oui_vendor
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
device_data = {
|
device_data = {
|
||||||
'mac': device.address,
|
'mac': device.address,
|
||||||
'address_type': device.address_type,
|
'address_type': device.address_type,
|
||||||
'device_key': device.device_key,
|
'device_key': device.device_key,
|
||||||
'name': device.name or 'Unknown',
|
'name': device.name or 'Unknown',
|
||||||
'rssi': device.rssi_current or -100,
|
'rssi': device.rssi_current or -100,
|
||||||
'rssi_median': device.rssi_median,
|
'rssi_median': device.rssi_median,
|
||||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||||
'type': _classify_device_type(device),
|
'type': _classify_device_type(device),
|
||||||
'manufacturer': manufacturer_name,
|
'manufacturer': manufacturer_name,
|
||||||
'manufacturer_id': device.manufacturer_id,
|
'manufacturer_id': device.manufacturer_id,
|
||||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||||
'protocol': device.protocol,
|
'protocol': device.protocol,
|
||||||
'first_seen': device.first_seen.isoformat(),
|
'first_seen': device.first_seen.isoformat(),
|
||||||
'last_seen': device.last_seen.isoformat(),
|
'last_seen': device.last_seen.isoformat(),
|
||||||
'seen_count': device.seen_count,
|
'seen_count': device.seen_count,
|
||||||
'range_band': device.range_band,
|
'range_band': device.range_band,
|
||||||
@@ -1229,38 +1229,38 @@ def get_device_timeseries(device_key: str):
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||||
"""Classify device type from available data."""
|
"""Classify device type from available data."""
|
||||||
name_lower = (device.name or '').lower()
|
name_lower = (device.name or '').lower()
|
||||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||||
service_uuids = device.service_uuids or []
|
service_uuids = device.service_uuids or []
|
||||||
|
|
||||||
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
||||||
if device.address and not device.is_randomized_mac:
|
if device.address and not device.is_randomized_mac:
|
||||||
try:
|
try:
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
oui_vendor = get_manufacturer(device.address)
|
oui_vendor = get_manufacturer(device.address)
|
||||||
if oui_vendor and oui_vendor != 'Unknown':
|
if oui_vendor and oui_vendor != 'Unknown':
|
||||||
manufacturer_lower = oui_vendor.lower()
|
manufacturer_lower = oui_vendor.lower()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def normalize_uuid(uuid: str) -> str:
|
def normalize_uuid(uuid: str) -> str:
|
||||||
if not uuid:
|
if not uuid:
|
||||||
return ''
|
return ''
|
||||||
value = str(uuid).lower().strip()
|
value = str(uuid).lower().strip()
|
||||||
if value.startswith('0x'):
|
if value.startswith('0x'):
|
||||||
value = value[2:]
|
value = value[2:]
|
||||||
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
||||||
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
||||||
return value[4:8]
|
return value[4:8]
|
||||||
if len(value) == 4:
|
if len(value) == 4:
|
||||||
return value
|
return value
|
||||||
return value
|
return value
|
||||||
|
|
||||||
# Check by name patterns
|
# Check by name patterns
|
||||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||||
return 'audio'
|
return 'audio'
|
||||||
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
|
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
|
||||||
return 'wearable'
|
return 'wearable'
|
||||||
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
|
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'
|
return 'computer'
|
||||||
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
|
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
|
||||||
return 'peripheral'
|
return 'peripheral'
|
||||||
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
|
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
|
||||||
return 'tracker'
|
return 'tracker'
|
||||||
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
|
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
|
||||||
return 'speaker'
|
return 'speaker'
|
||||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||||
return 'media'
|
return 'media'
|
||||||
|
|
||||||
# Tracker signals (metadata or Find My service)
|
# Tracker signals (metadata or Find My service)
|
||||||
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
||||||
return 'tracker'
|
return 'tracker'
|
||||||
|
|
||||||
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
||||||
if 'fd6f' in normalized_uuids:
|
if 'fd6f' in normalized_uuids:
|
||||||
return 'tracker'
|
return 'tracker'
|
||||||
|
|
||||||
# Service UUIDs (GATT / classic)
|
# Service UUIDs (GATT / classic)
|
||||||
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
||||||
wearable_uuids = {'180d', '1814', '1816'}
|
wearable_uuids = {'180d', '1814', '1816'}
|
||||||
hid_uuids = {'1812'}
|
hid_uuids = {'1812'}
|
||||||
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
||||||
|
|
||||||
if normalized_uuids & audio_uuids:
|
if normalized_uuids & audio_uuids:
|
||||||
return 'audio'
|
return 'audio'
|
||||||
if normalized_uuids & hid_uuids:
|
if normalized_uuids & hid_uuids:
|
||||||
return 'peripheral'
|
return 'peripheral'
|
||||||
if normalized_uuids & wearable_uuids:
|
if normalized_uuids & wearable_uuids:
|
||||||
return 'wearable'
|
return 'wearable'
|
||||||
if normalized_uuids & beacon_uuids:
|
if normalized_uuids & beacon_uuids:
|
||||||
return 'beacon'
|
return 'beacon'
|
||||||
|
|
||||||
# Check by manufacturer
|
# Check by manufacturer
|
||||||
if 'apple' in manufacturer_lower:
|
if 'apple' in manufacturer_lower:
|
||||||
return 'apple_device'
|
return 'apple_device'
|
||||||
if 'samsung' in manufacturer_lower:
|
if 'samsung' in manufacturer_lower:
|
||||||
return 'samsung_device'
|
return 'samsung_device'
|
||||||
|
|
||||||
# Check by class of device
|
# Check by class of device
|
||||||
if device.major_class:
|
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'})
|
||||||
+308
-99
@@ -21,6 +21,7 @@ from utils.sse import format_sse
|
|||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -39,10 +40,18 @@ dmr_rtl_process: Optional[subprocess.Popen] = None
|
|||||||
dmr_dsd_process: Optional[subprocess.Popen] = None
|
dmr_dsd_process: Optional[subprocess.Popen] = None
|
||||||
dmr_thread: Optional[threading.Thread] = None
|
dmr_thread: Optional[threading.Thread] = None
|
||||||
dmr_running = False
|
dmr_running = False
|
||||||
|
dmr_has_audio = False # True when ffmpeg available and dsd outputs audio
|
||||||
dmr_lock = threading.Lock()
|
dmr_lock = threading.Lock()
|
||||||
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
dmr_active_device: Optional[int] = None
|
dmr_active_device: Optional[int] = None
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
_ffmpeg_sinks: set[object] = set()
|
||||||
|
_ffmpeg_sinks_lock = threading.Lock()
|
||||||
|
|
||||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||||
|
|
||||||
# Classic dsd flags
|
# Classic dsd flags
|
||||||
@@ -56,14 +65,16 @@ _DSD_PROTOCOL_FLAGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# dsd-fme remapped several flags from classic DSD:
|
# dsd-fme remapped several flags from classic DSD:
|
||||||
# -fp = ProVoice (NOT P25), -fi = NXDN48 (NOT D-Star),
|
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
|
||||||
# -f1 = P25 Phase 1, -ft = XDMA multi-protocol decoder
|
# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25),
|
||||||
|
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
|
||||||
|
# -ft = XDMA multi-protocol decoder
|
||||||
_DSD_FME_PROTOCOL_FLAGS = {
|
_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': ['-fd'], # DMR (classic flag, works in dsd-fme)
|
'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
|
'nxdn': ['-fn'], # NXDN96
|
||||||
'dstar': [], # No dedicated flag in dsd-fme; auto-detect
|
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
|
||||||
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +82,6 @@ _DSD_FME_PROTOCOL_FLAGS = {
|
|||||||
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||||
_DSD_FME_MODULATION = {
|
_DSD_FME_MODULATION = {
|
||||||
'dmr': ['-mc'], # C4FM
|
'dmr': ['-mc'], # C4FM
|
||||||
'p25': ['-mc'], # C4FM (Phase 1; Phase 2 would use -mq)
|
|
||||||
'nxdn': ['-mc'], # C4FM
|
'nxdn': ['-mc'], # C4FM
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +110,16 @@ def find_rtl_fm() -> str | None:
|
|||||||
return shutil.which('rtl_fm')
|
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')
|
||||||
|
|
||||||
|
|
||||||
def parse_dsd_output(line: str) -> dict | None:
|
def parse_dsd_output(line: str) -> dict | None:
|
||||||
"""Parse a line of DSD stderr output into a structured event.
|
"""Parse a line of DSD stderr output into a structured event.
|
||||||
|
|
||||||
@@ -111,8 +131,11 @@ def parse_dsd_output(line: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
|
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
|
||||||
# These contain box-drawing characters or are pure decoration.
|
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
|
||||||
if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line):
|
# characters (│, ─) as column separators in DATA lines, so we must not
|
||||||
|
# discard lines that also contain alphanumeric content.
|
||||||
|
stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line)
|
||||||
|
if not stripped_of_box:
|
||||||
return None
|
return None
|
||||||
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
||||||
return None
|
return None
|
||||||
@@ -132,8 +155,9 @@ def parse_dsd_output(line: str) -> dict | None:
|
|||||||
# is captured as a call event rather than a bare slot event.
|
# is captured as a call event rather than a bare slot event.
|
||||||
# Classic dsd: "TG: 12345 Src: 67890"
|
# Classic dsd: "TG: 12345 Src: 67890"
|
||||||
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
|
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
|
||||||
|
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
|
||||||
tg_match = re.search(
|
tg_match = re.search(
|
||||||
r'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE
|
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
|
||||||
)
|
)
|
||||||
if tg_match:
|
if tg_match:
|
||||||
result = {
|
result = {
|
||||||
@@ -192,6 +216,97 @@ def parse_dsd_output(line: str) -> dict | None:
|
|||||||
|
|
||||||
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
|
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
|
||||||
|
|
||||||
|
# 100ms of silence at 8kHz 16-bit mono = 1600 bytes
|
||||||
|
_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 streaming clients are connected, forwards data to all
|
||||||
|
active ffmpeg stdin sinks with silence fill during voice gaps.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while dmr_running:
|
||||||
|
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
|
||||||
|
if ready:
|
||||||
|
data = os.read(dsd_stdout.fileno(), 4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
sinks = _get_audio_sinks()
|
||||||
|
for sink in sinks:
|
||||||
|
try:
|
||||||
|
sink.write(data)
|
||||||
|
sink.flush()
|
||||||
|
except (BrokenPipeError, OSError, ValueError):
|
||||||
|
_unregister_audio_sink(sink)
|
||||||
|
else:
|
||||||
|
# No audio from decoder — feed silence if client connected
|
||||||
|
sinks = _get_audio_sinks()
|
||||||
|
for sink in sinks:
|
||||||
|
try:
|
||||||
|
sink.write(_SILENCE_CHUNK)
|
||||||
|
sink.flush()
|
||||||
|
except (BrokenPipeError, OSError, ValueError):
|
||||||
|
_unregister_audio_sink(sink)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _queue_put(event: dict):
|
def _queue_put(event: dict):
|
||||||
"""Put an event on the DMR queue, dropping oldest if full."""
|
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||||
@@ -239,6 +354,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
|||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
logger.debug("DSD raw: %s", text)
|
||||||
parsed = parse_dsd_output(text)
|
parsed = parse_dsd_output(text)
|
||||||
if parsed:
|
if parsed:
|
||||||
_queue_put(parsed)
|
_queue_put(parsed)
|
||||||
@@ -258,7 +374,11 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
|||||||
logger.error(f"DSD stream error: {e}")
|
logger.error(f"DSD stream error: {e}")
|
||||||
finally:
|
finally:
|
||||||
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
||||||
|
global dmr_has_audio
|
||||||
dmr_running = False
|
dmr_running = False
|
||||||
|
dmr_has_audio = False
|
||||||
|
with _ffmpeg_sinks_lock:
|
||||||
|
_ffmpeg_sinks.clear()
|
||||||
# Capture exit info for diagnostics
|
# Capture exit info for diagnostics
|
||||||
rc = dsd_process.poll()
|
rc = dsd_process.poll()
|
||||||
reason = 'stopped'
|
reason = 'stopped'
|
||||||
@@ -272,19 +392,9 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||||
# Cleanup both processes
|
# Cleanup decoder + demod processes
|
||||||
for proc in [dsd_process, rtl_process]:
|
_stop_process(dsd_process)
|
||||||
if proc and proc.poll() is None:
|
_stop_process(rtl_process)
|
||||||
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_rtl_process = None
|
||||||
dmr_dsd_process = None
|
dmr_dsd_process = None
|
||||||
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||||
@@ -304,10 +414,14 @@ def check_tools() -> Response:
|
|||||||
"""Check for required tools."""
|
"""Check for required tools."""
|
||||||
dsd_path, _ = find_dsd()
|
dsd_path, _ = find_dsd()
|
||||||
rtl_fm = find_rtl_fm()
|
rtl_fm = find_rtl_fm()
|
||||||
|
rx_fm = find_rx_fm()
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'dsd': dsd_path is not None,
|
'dsd': dsd_path is not None,
|
||||||
'rtl_fm': rtl_fm is not None,
|
'rtl_fm': rtl_fm is not None,
|
||||||
'available': dsd_path is not None and 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 or rx_fm is not None),
|
||||||
'protocols': VALID_PROTOCOLS,
|
'protocols': VALID_PROTOCOLS,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -315,20 +429,13 @@ def check_tools() -> Response:
|
|||||||
@dmr_bp.route('/start', methods=['POST'])
|
@dmr_bp.route('/start', methods=['POST'])
|
||||||
def start_dmr() -> Response:
|
def start_dmr() -> Response:
|
||||||
"""Start digital voice decoding."""
|
"""Start digital voice decoding."""
|
||||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device
|
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()
|
dsd_path, is_fme = find_dsd()
|
||||||
if not dsd_path:
|
if not dsd_path:
|
||||||
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
|
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 {}
|
data = request.json or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -340,9 +447,25 @@ def start_dmr() -> Response:
|
|||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
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:
|
if protocol not in VALID_PROTOCOLS:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
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
|
# Clear stale queue
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -350,40 +473,63 @@ def start_dmr() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
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
|
# Claim SDR device — use protocol name so the device panel shows
|
||||||
# "D-STAR", "P25", etc. instead of always "DMR"
|
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||||
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||||
error = app_module.claim_sdr_device(device, mode_label)
|
error = app_module.claim_sdr_device(device, mode_label)
|
||||||
if error:
|
if error:
|
||||||
|
with dmr_lock:
|
||||||
|
dmr_running = False
|
||||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||||
|
|
||||||
dmr_active_device = device
|
dmr_active_device = device
|
||||||
|
|
||||||
freq_hz = int(frequency * 1e6)
|
# Build FM demodulation command via SDR abstraction.
|
||||||
|
try:
|
||||||
# Build rtl_fm command (48kHz sample rate for DSD).
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
# mid-frame, destroying DSD sync. The decoder handles silence
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
# internally via its own frame-sync detection.
|
device=sdr_device,
|
||||||
rtl_cmd = [
|
frequency_mhz=frequency,
|
||||||
rtl_fm_path,
|
sample_rate=48000,
|
||||||
'-M', 'fm',
|
gain=float(gain) if gain > 0 else None,
|
||||||
'-f', str(freq_hz),
|
ppm=int(ppm) if ppm != 0 else None,
|
||||||
'-s', '48000',
|
modulation='fm',
|
||||||
'-g', str(gain),
|
squelch=None,
|
||||||
'-d', str(device),
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
'-l', '0',
|
)
|
||||||
]
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
if ppm != 0:
|
# Keep squelch fully open for digital bitstreams.
|
||||||
rtl_cmd.extend(['-p', str(ppm)])
|
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
|
# Build DSD command
|
||||||
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
|
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
|
||||||
# instead of PulseAudio which may not be available under sudo
|
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
|
||||||
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
|
# If ffmpeg is unavailable, fall back to discarding audio.
|
||||||
|
ffmpeg_path = find_ffmpeg()
|
||||||
|
if ffmpeg_path:
|
||||||
|
audio_out = '-'
|
||||||
|
else:
|
||||||
|
audio_out = 'null' if is_fme else '-'
|
||||||
|
logger.warning("ffmpeg not found — audio streaming disabled, data-only mode")
|
||||||
|
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
|
||||||
if is_fme:
|
if is_fme:
|
||||||
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
|
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
|
||||||
|
# Event log to stderr so we capture TG/Source/Voice data that
|
||||||
|
# dsd-fme may not output on stderr by default.
|
||||||
|
dsd_cmd.extend(['-J', '/dev/stderr'])
|
||||||
# Relax CRC checks for marginal signals — lets more frames
|
# Relax CRC checks for marginal signals — lets more frames
|
||||||
# through at the cost of occasional decode errors.
|
# through at the cost of occasional decode errors.
|
||||||
if data.get('relaxCrc', False):
|
if data.get('relaxCrc', False):
|
||||||
@@ -399,10 +545,13 @@ def start_dmr() -> Response:
|
|||||||
)
|
)
|
||||||
register_process(dmr_rtl_process)
|
register_process(dmr_rtl_process)
|
||||||
|
|
||||||
|
# DSD stdout → PIPE when ffmpeg available (audio pipeline),
|
||||||
|
# otherwise DEVNULL (data-only mode)
|
||||||
|
dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL
|
||||||
dmr_dsd_process = subprocess.Popen(
|
dmr_dsd_process = subprocess.Popen(
|
||||||
dsd_cmd,
|
dsd_cmd,
|
||||||
stdin=dmr_rtl_process.stdout,
|
stdin=dmr_rtl_process.stdout,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=dsd_stdout,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
register_process(dmr_dsd_process)
|
register_process(dmr_dsd_process)
|
||||||
@@ -410,6 +559,17 @@ def start_dmr() -> Response:
|
|||||||
# Allow rtl_fm to send directly to dsd
|
# Allow rtl_fm to send directly to dsd
|
||||||
dmr_rtl_process.stdout.close()
|
dmr_rtl_process.stdout.close()
|
||||||
|
|
||||||
|
# Start mux thread: always drains dsd-fme stdout to prevent the
|
||||||
|
# process from blocking (which would freeze stderr / text data).
|
||||||
|
# ffmpeg is started lazily per-client in /dmr/audio/stream.
|
||||||
|
if ffmpeg_path and dmr_dsd_process.stdout:
|
||||||
|
dmr_has_audio = True
|
||||||
|
threading.Thread(
|
||||||
|
target=_dsd_audio_mux,
|
||||||
|
args=(dmr_dsd_process.stdout,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
rtl_rc = dmr_rtl_process.poll()
|
rtl_rc = dmr_rtl_process.poll()
|
||||||
@@ -423,24 +583,8 @@ def start_dmr() -> Response:
|
|||||||
if dmr_dsd_process.stderr:
|
if dmr_dsd_process.stderr:
|
||||||
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
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}")
|
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 process and unregister both
|
# Terminate surviving processes and release resources.
|
||||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
_reset_runtime_state(release_device=True)
|
||||||
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
|
|
||||||
# Surface a clear error to the user
|
# Surface a clear error to the user
|
||||||
detail = rtl_err.strip() or dsd_err.strip()
|
detail = rtl_err.strip() or dsd_err.strip()
|
||||||
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||||
@@ -461,7 +605,6 @@ def start_dmr() -> Response:
|
|||||||
|
|
||||||
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
|
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
|
||||||
|
|
||||||
dmr_running = True
|
|
||||||
dmr_thread = threading.Thread(
|
dmr_thread = threading.Thread(
|
||||||
target=stream_dsd_output,
|
target=stream_dsd_output,
|
||||||
args=(dmr_rtl_process, dmr_dsd_process),
|
args=(dmr_rtl_process, dmr_dsd_process),
|
||||||
@@ -473,43 +616,21 @@ def start_dmr() -> Response:
|
|||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'protocol': protocol,
|
'protocol': protocol,
|
||||||
|
'sdr_type': sdr_type.value,
|
||||||
|
'has_audio': dmr_has_audio,
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start DMR: {e}")
|
logger.error(f"Failed to start DMR: {e}")
|
||||||
if dmr_active_device is not None:
|
_reset_runtime_state(release_device=True)
|
||||||
app_module.release_sdr_device(dmr_active_device)
|
|
||||||
dmr_active_device = None
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@dmr_bp.route('/stop', methods=['POST'])
|
@dmr_bp.route('/stop', methods=['POST'])
|
||||||
def stop_dmr() -> Response:
|
def stop_dmr() -> Response:
|
||||||
"""Stop digital voice decoding."""
|
"""Stop digital voice decoding."""
|
||||||
global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device
|
|
||||||
|
|
||||||
with dmr_lock:
|
with dmr_lock:
|
||||||
dmr_running = False
|
_reset_runtime_state(release_device=True)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -520,9 +641,97 @@ def dmr_status() -> Response:
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'running': dmr_running,
|
'running': dmr_running,
|
||||||
'device': dmr_active_device,
|
'device': dmr_active_device,
|
||||||
|
'has_audio': dmr_has_audio,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/audio/stream')
|
||||||
|
def stream_dmr_audio() -> Response:
|
||||||
|
"""Stream decoded digital voice audio as WAV.
|
||||||
|
|
||||||
|
Starts a per-client ffmpeg encoder. The global mux thread
|
||||||
|
(_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while
|
||||||
|
the client is connected, and discards audio otherwise. This avoids
|
||||||
|
the pipe-buffer deadlock that occurs when ffmpeg is started at
|
||||||
|
decoder launch (its stdout fills up before any HTTP client reads
|
||||||
|
it, back-pressuring the entire pipeline and freezing stderr/text
|
||||||
|
data output).
|
||||||
|
"""
|
||||||
|
if not dmr_running or not dmr_has_audio:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
|
ffmpeg_path = find_ffmpeg()
|
||||||
|
if not ffmpeg_path:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=503)
|
||||||
|
|
||||||
|
encoder_cmd = [
|
||||||
|
ffmpeg_path, '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-fflags', 'nobuffer', '-flags', 'low_delay',
|
||||||
|
'-probesize', '32', '-analyzeduration', '0',
|
||||||
|
'-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0',
|
||||||
|
'-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1',
|
||||||
|
]
|
||||||
|
audio_proc = subprocess.Popen(
|
||||||
|
encoder_cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
# Drain ffmpeg stderr to prevent blocking
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda p: [None for _ in p.stderr],
|
||||||
|
args=(audio_proc,), daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
if audio_proc.stdin:
|
||||||
|
_register_audio_sink(audio_proc.stdin)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
try:
|
||||||
|
while dmr_running and audio_proc.poll() is None:
|
||||||
|
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
|
||||||
|
if ready:
|
||||||
|
chunk = audio_proc.stdout.read(4096)
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if audio_proc.poll() is not None:
|
||||||
|
break
|
||||||
|
except GeneratorExit:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DMR audio stream error: {e}")
|
||||||
|
finally:
|
||||||
|
# Disconnect mux → ffmpeg, then clean up
|
||||||
|
if audio_proc.stdin:
|
||||||
|
_unregister_audio_sink(audio_proc.stdin)
|
||||||
|
try:
|
||||||
|
audio_proc.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
audio_proc.terminate()
|
||||||
|
audio_proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
audio_proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate(),
|
||||||
|
mimetype='audio/wav',
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dmr_bp.route('/stream')
|
@dmr_bp.route('/stream')
|
||||||
def stream_dmr() -> Response:
|
def stream_dmr() -> Response:
|
||||||
"""SSE stream for DMR decoder events."""
|
"""SSE stream for DMR decoder events."""
|
||||||
|
|||||||
+99
-32
@@ -4,19 +4,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import queue
|
import queue
|
||||||
import time
|
import time
|
||||||
from typing import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
|
from utils.gps import (
|
||||||
|
GPSPosition,
|
||||||
|
GPSSkyData,
|
||||||
|
detect_gps_devices,
|
||||||
|
get_current_position,
|
||||||
|
get_gps_reader,
|
||||||
|
is_gpsd_running,
|
||||||
|
start_gpsd,
|
||||||
|
start_gpsd_daemon,
|
||||||
|
stop_gps,
|
||||||
|
stop_gpsd_daemon,
|
||||||
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.gps import (
|
|
||||||
get_gps_reader,
|
|
||||||
start_gpsd,
|
|
||||||
stop_gps,
|
|
||||||
get_current_position,
|
|
||||||
GPSPosition,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -29,12 +34,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
def _position_callback(position: GPSPosition) -> None:
|
def _position_callback(position: GPSPosition) -> None:
|
||||||
"""Callback to queue position updates for SSE stream."""
|
"""Callback to queue position updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_gps_queue.put_nowait(position.to_dict())
|
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Discard oldest if queue is full
|
# Discard oldest if queue is full
|
||||||
try:
|
try:
|
||||||
_gps_queue.get_nowait()
|
_gps_queue.get_nowait()
|
||||||
_gps_queue.put_nowait(position.to_dict())
|
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sky_callback(sky: GPSSkyData) -> None:
|
||||||
|
"""Callback to queue sky data updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_gps_queue.get_nowait()
|
||||||
|
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -45,36 +62,44 @@ def auto_connect_gps():
|
|||||||
Automatically connect to gpsd if available.
|
Automatically connect to gpsd if available.
|
||||||
|
|
||||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||||
|
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||||
Returns current status if already connected.
|
Returns current status if already connected.
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': position is not None,
|
'has_fix': position is not None,
|
||||||
'position': position.to_dict() if position else None
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Try to connect to gpsd on localhost:2947
|
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
port = 2947
|
port = 2947
|
||||||
|
|
||||||
# First check if gpsd is reachable
|
# If gpsd isn't running, try to detect a device and start it
|
||||||
try:
|
if not is_gpsd_running(host, port):
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
devices = detect_gps_devices()
|
||||||
sock.settimeout(1.0)
|
if not devices:
|
||||||
sock.connect((host, port))
|
return jsonify({
|
||||||
sock.close()
|
'status': 'unavailable',
|
||||||
except Exception:
|
'message': 'No GPS device detected'
|
||||||
return jsonify({
|
})
|
||||||
'status': 'unavailable',
|
|
||||||
'message': 'gpsd not running'
|
# Try to start gpsd with the first detected device
|
||||||
})
|
device_path = devices[0]['path']
|
||||||
|
success, msg = start_gpsd_daemon(device_path, host, port)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': msg,
|
||||||
|
'devices': devices,
|
||||||
|
})
|
||||||
|
logger.info(f"Auto-started gpsd on {device_path}")
|
||||||
|
|
||||||
# Clear the queue
|
# Clear the queue
|
||||||
while not _gps_queue.empty():
|
while not _gps_queue.empty():
|
||||||
@@ -84,14 +109,17 @@ def auto_connect_gps():
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Start the gpsd client
|
# Start the gpsd client
|
||||||
success = start_gpsd(host, port, callback=_position_callback)
|
success = start_gpsd(host, port,
|
||||||
|
callback=_position_callback,
|
||||||
|
sky_callback=_sky_callback)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': False,
|
'has_fix': False,
|
||||||
'position': None
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -100,14 +128,26 @@ def auto_connect_gps():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/devices')
|
||||||
|
def list_gps_devices():
|
||||||
|
"""List detected GPS serial devices."""
|
||||||
|
devices = detect_gps_devices()
|
||||||
|
return jsonify({
|
||||||
|
'devices': devices,
|
||||||
|
'gpsd_running': is_gpsd_running(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stop', methods=['POST'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS client."""
|
"""Stop GPS client and gpsd daemon if we started it."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader:
|
if reader:
|
||||||
reader.remove_callback(_position_callback)
|
reader.remove_callback(_position_callback)
|
||||||
|
reader.remove_sky_callback(_sky_callback)
|
||||||
|
|
||||||
stop_gps()
|
stop_gps()
|
||||||
|
stop_gpsd_daemon()
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -122,15 +162,18 @@ def get_gps_status():
|
|||||||
'running': False,
|
'running': False,
|
||||||
'device': None,
|
'device': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
'error': None,
|
'error': None,
|
||||||
'message': 'GPS client not started'
|
'message': 'GPS client not started'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': reader.is_running,
|
'running': reader.is_running,
|
||||||
'device': reader.device_path,
|
'device': reader.device_path,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||||
'error': reader.error,
|
'error': reader.error,
|
||||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||||
@@ -161,18 +204,42 @@ def get_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/satellites')
|
||||||
|
def get_satellites():
|
||||||
|
"""Get current satellite sky view data."""
|
||||||
|
reader = get_gps_reader()
|
||||||
|
|
||||||
|
if not reader or not reader.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'GPS client not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
sky = reader.sky
|
||||||
|
if sky:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'sky': sky.to_dict()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'waiting',
|
||||||
|
'message': 'Waiting for satellite data'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position updates."""
|
"""SSE stream of GPS position and sky updates."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
position = _gps_queue.get(timeout=1)
|
data = _gps_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
yield format_sse({'type': 'position', **position})
|
yield format_sse(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
+76
-38
@@ -102,15 +102,21 @@ def find_ffmpeg() -> str | None:
|
|||||||
return shutil.which('ffmpeg')
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
|
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
|
||||||
|
|
||||||
|
|
||||||
def normalize_modulation(value: str) -> str:
|
def normalize_modulation(value: str) -> str:
|
||||||
"""Normalize and validate modulation string."""
|
"""Normalize and validate modulation string."""
|
||||||
mod = str(value or '').lower().strip()
|
mod = str(value or '').lower().strip()
|
||||||
if mod not in VALID_MODULATIONS:
|
if mod not in VALID_MODULATIONS:
|
||||||
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
|
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
|
||||||
return mod
|
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
|
resample_rate = 24000
|
||||||
|
|
||||||
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
||||||
rtl_cmd = [
|
rtl_cmd = [
|
||||||
rtl_fm_path,
|
rtl_fm_path,
|
||||||
'-M', mod,
|
'-M', _rtl_fm_demod_mode(mod),
|
||||||
'-f', str(freq_hz),
|
'-f', str(freq_hz),
|
||||||
'-s', str(sample_rate),
|
'-s', str(sample_rate),
|
||||||
'-r', str(resample_rate),
|
'-r', str(resample_rate),
|
||||||
'-g', str(gain),
|
'-g', str(gain),
|
||||||
'-d', str(device),
|
'-d', str(device),
|
||||||
]
|
]
|
||||||
# Add bias-t flag if enabled (for external LNA power)
|
# Add bias-t flag if enabled (for external LNA power)
|
||||||
if scanner_config.get('bias_t', False):
|
if scanner_config.get('bias_t', False):
|
||||||
@@ -679,14 +685,14 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
return
|
return
|
||||||
|
|
||||||
freq_hz = int(frequency * 1e6)
|
freq_hz = int(frequency * 1e6)
|
||||||
sdr_cmd = [
|
sdr_cmd = [
|
||||||
rtl_fm_path,
|
rtl_fm_path,
|
||||||
'-M', modulation,
|
'-M', _rtl_fm_demod_mode(modulation),
|
||||||
'-f', str(freq_hz),
|
'-f', str(freq_hz),
|
||||||
'-s', str(sample_rate),
|
'-s', str(sample_rate),
|
||||||
'-r', str(resample_rate),
|
'-r', str(resample_rate),
|
||||||
'-g', str(scanner_config['gain']),
|
'-g', str(scanner_config['gain']),
|
||||||
'-d', str(scanner_config['device']),
|
'-d', str(scanner_config['device']),
|
||||||
'-l', str(scanner_config['squelch']),
|
'-l', str(scanner_config['squelch']),
|
||||||
]
|
]
|
||||||
if scanner_config.get('bias_t', False):
|
if scanner_config.get('bias_t', False):
|
||||||
@@ -1310,20 +1316,35 @@ def start_audio() -> Response:
|
|||||||
_stop_waterfall_internal()
|
_stop_waterfall_internal()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
# Release waterfall device claim if the WebSocket waterfall is still
|
# Claim device for listening audio. The WebSocket waterfall handler
|
||||||
# holding it. The JS client sends a stop command and closes the
|
# may still be tearing down its IQ capture process (thread join +
|
||||||
# WebSocket before requesting audio, but the backend handler may not
|
# safe_terminate can take several seconds), so we retry with back-off
|
||||||
# have finished its cleanup yet.
|
# to give the USB device time to be fully released.
|
||||||
device_status = app_module.get_sdr_device_status()
|
|
||||||
if device_status.get(device) == 'waterfall':
|
|
||||||
app_module.release_sdr_device(device)
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
# Claim device for listening audio
|
|
||||||
if listening_active_device is None or listening_active_device != device:
|
if listening_active_device is None or listening_active_device != device:
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(listening_active_device)
|
||||||
error = app_module.claim_sdr_device(device, 'listening')
|
listening_active_device = None
|
||||||
|
|
||||||
|
error = None
|
||||||
|
max_claim_attempts = 6
|
||||||
|
for attempt in range(max_claim_attempts):
|
||||||
|
# Force-release a stale waterfall registry entry on each
|
||||||
|
# attempt — the WebSocket handler may not have finished
|
||||||
|
# cleanup yet.
|
||||||
|
device_status = app_module.get_sdr_device_status()
|
||||||
|
if device_status.get(device) == 'waterfall':
|
||||||
|
app_module.release_sdr_device(device)
|
||||||
|
|
||||||
|
error = app_module.claim_sdr_device(device, 'listening')
|
||||||
|
if not error:
|
||||||
|
break
|
||||||
|
if attempt < max_claim_attempts - 1:
|
||||||
|
logger.debug(
|
||||||
|
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||||
|
f"failed, retrying in 0.5s: {error}"
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1441,13 +1462,30 @@ def stream_audio() -> Response:
|
|||||||
if not proc or not proc.stdout:
|
if not proc or not proc.stdout:
|
||||||
return
|
return
|
||||||
try:
|
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
|
first_chunk_deadline = time.time() + 3.0
|
||||||
while audio_running and proc.poll() is None:
|
while audio_running and proc.poll() is None:
|
||||||
# Use select to avoid blocking forever
|
# Use select to avoid blocking forever
|
||||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||||
if ready:
|
if ready:
|
||||||
chunk = proc.stdout.read(4096)
|
chunk = proc.stdout.read(8192)
|
||||||
if chunk:
|
if chunk:
|
||||||
yield chunk
|
yield chunk
|
||||||
else:
|
else:
|
||||||
|
|||||||
+121
-15
@@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response
|
|||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
|
from utils.database import (
|
||||||
|
get_tracked_satellites,
|
||||||
|
add_tracked_satellite,
|
||||||
|
bulk_add_tracked_satellites,
|
||||||
|
update_tracked_satellite,
|
||||||
|
remove_tracked_satellite,
|
||||||
|
)
|
||||||
from utils.logging import satellite_logger as logger
|
from utils.logging import satellite_logger as logger
|
||||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||||
|
|
||||||
@@ -31,6 +38,43 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_db_satellites_into_cache():
|
||||||
|
"""Load user-tracked satellites from DB into the TLE cache."""
|
||||||
|
global _tle_cache
|
||||||
|
try:
|
||||||
|
db_sats = get_tracked_satellites()
|
||||||
|
loaded = 0
|
||||||
|
for sat in db_sats:
|
||||||
|
if sat['tle_line1'] and sat['tle_line2']:
|
||||||
|
# Use a cache key derived from name (sanitised)
|
||||||
|
cache_key = sat['name'].replace(' ', '-').upper()
|
||||||
|
if cache_key not in _tle_cache:
|
||||||
|
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
|
||||||
|
loaded += 1
|
||||||
|
if loaded:
|
||||||
|
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def init_tle_auto_refresh():
|
||||||
|
"""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")
|
||||||
|
|
||||||
|
|
||||||
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Fetch real-time ISS position from external APIs.
|
Fetch real-time ISS position from external APIs.
|
||||||
@@ -153,15 +197,11 @@ def predict_passes():
|
|||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
25338: 'NOAA-15',
|
|
||||||
28654: 'NOAA-18',
|
|
||||||
33591: 'NOAA-19',
|
|
||||||
43013: 'NOAA-20',
|
|
||||||
40069: 'METEOR-M2',
|
40069: 'METEOR-M2',
|
||||||
57166: 'METEOR-M2-3'
|
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 = []
|
satellites = []
|
||||||
for sat in sat_input:
|
for sat in sat_input:
|
||||||
if isinstance(sat, int) and sat in norad_to_name:
|
if isinstance(sat, int) and sat in norad_to_name:
|
||||||
@@ -172,10 +212,6 @@ def predict_passes():
|
|||||||
passes = []
|
passes = []
|
||||||
colors = {
|
colors = {
|
||||||
'ISS': '#00ffff',
|
'ISS': '#00ffff',
|
||||||
'NOAA-15': '#00ff00',
|
|
||||||
'NOAA-18': '#ff6600',
|
|
||||||
'NOAA-19': '#ff3366',
|
|
||||||
'NOAA-20': '#00ffaa',
|
|
||||||
'METEOR-M2': '#9370DB',
|
'METEOR-M2': '#9370DB',
|
||||||
'METEOR-M2-3': '#ff00ff'
|
'METEOR-M2-3': '#ff00ff'
|
||||||
}
|
}
|
||||||
@@ -312,10 +348,6 @@ def get_satellite_position():
|
|||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
25338: 'NOAA-15',
|
|
||||||
28654: 'NOAA-18',
|
|
||||||
33591: 'NOAA-19',
|
|
||||||
43013: 'NOAA-20',
|
|
||||||
40069: 'METEOR-M2',
|
40069: 'METEOR-M2',
|
||||||
57166: 'METEOR-M2-3'
|
57166: 'METEOR-M2-3'
|
||||||
}
|
}
|
||||||
@@ -481,7 +513,8 @@ def update_tle():
|
|||||||
'updated': updated
|
'updated': updated
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
logger.error(f"Error updating TLE data: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': 'TLE update failed'})
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/celestrak/<category>')
|
@satellite_bp.route('/celestrak/<category>')
|
||||||
@@ -535,4 +568,77 @@ def fetch_celestrak(category):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(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
|
||||||
|
|||||||
+7
-1
@@ -199,10 +199,16 @@ def start_sensor() -> Response:
|
|||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
# Monitor stderr
|
# Monitor stderr
|
||||||
|
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||||
|
_stderr_noise = (
|
||||||
|
'bitbuffer_add_bit',
|
||||||
|
'row count limit',
|
||||||
|
)
|
||||||
|
|
||||||
def monitor_stderr():
|
def monitor_stderr():
|
||||||
for line in app_module.sensor_process.stderr:
|
for line in app_module.sensor_process.stderr:
|
||||||
err = line.decode('utf-8', errors='replace').strip()
|
err = line.decode('utf-8', errors='replace').strip()
|
||||||
if err:
|
if err and not any(noise in err for noise in _stderr_noise):
|
||||||
logger.debug(f"[rtl_433] {err}")
|
logger.debug(f"[rtl_433] {err}")
|
||||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"""Weather Satellite decoder routes.
|
||||||
|
|
||||||
|
Provides endpoints for capturing and decoding weather satellite images
|
||||||
|
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
|
||||||
|
from utils.weather_sat import (
|
||||||
|
get_weather_sat_decoder,
|
||||||
|
is_weather_sat_available,
|
||||||
|
CaptureProgress,
|
||||||
|
WEATHER_SATELLITES,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.weather_sat')
|
||||||
|
|
||||||
|
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||||
|
|
||||||
|
# Queue for SSE progress streaming
|
||||||
|
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_callback(progress: CaptureProgress) -> None:
|
||||||
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
"""Get weather satellite decoder status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with decoder availability and current status.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
return jsonify(decoder.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/satellites')
|
||||||
|
def list_satellites():
|
||||||
|
"""Get list of supported weather satellites with frequencies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with satellite definitions.
|
||||||
|
"""
|
||||||
|
satellites = []
|
||||||
|
for key, info in WEATHER_SATELLITES.items():
|
||||||
|
satellites.append({
|
||||||
|
'key': key,
|
||||||
|
'name': info['name'],
|
||||||
|
'frequency': info['frequency'],
|
||||||
|
'mode': info['mode'],
|
||||||
|
'description': info['description'],
|
||||||
|
'active': info['active'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'satellites': satellites,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/start', methods=['POST'])
|
||||||
|
def start_capture():
|
||||||
|
"""Start weather satellite capture and decode.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"satellite": "NOAA-18", // Required: satellite key
|
||||||
|
"device": 0, // RTL-SDR device index (default: 0)
|
||||||
|
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||||
|
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
if not is_weather_sat_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'satellite': decoder.current_satellite,
|
||||||
|
'frequency': decoder.current_frequency,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Validate satellite
|
||||||
|
satellite = data.get('satellite')
|
||||||
|
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate device index and gain
|
||||||
|
try:
|
||||||
|
device_index = validate_device_index(data.get('device', 0))
|
||||||
|
gain = validate_gain(data.get('gain', 40.0))
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning('Invalid parameter in start_capture: %s', e)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid parameter value'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error,
|
||||||
|
}), 409
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not _weather_sat_queue.empty():
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Set callback and on-complete handler for SDR release
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
|
||||||
|
def _release_device():
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
decoder.set_on_complete(_release_device)
|
||||||
|
|
||||||
|
success = decoder.start(
|
||||||
|
satellite=satellite,
|
||||||
|
device_index=device_index,
|
||||||
|
gain=gain,
|
||||||
|
bias_t=bias_t,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
sat_info = WEATHER_SATELLITES[satellite]
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'satellite': satellite,
|
||||||
|
'frequency': sat_info['frequency'],
|
||||||
|
'mode': sat_info['mode'],
|
||||||
|
'device': device_index,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Release device on failure
|
||||||
|
_release_device()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start capture'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/test-decode', methods=['POST'])
|
||||||
|
def test_decode():
|
||||||
|
"""Start weather satellite decode from a pre-recorded file.
|
||||||
|
|
||||||
|
No SDR hardware is required — decodes an IQ baseband or WAV file
|
||||||
|
using SatDump offline mode.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"satellite": "NOAA-18", // Required: satellite key
|
||||||
|
"input_file": "/path/to/file", // Required: server-side file path
|
||||||
|
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
if not is_weather_sat_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'satellite': decoder.current_satellite,
|
||||||
|
'frequency': decoder.current_frequency,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Validate satellite
|
||||||
|
satellite = data.get('satellite')
|
||||||
|
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate input file
|
||||||
|
input_file = data.get('input_file')
|
||||||
|
if not input_file:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'input_file is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
input_path = Path(input_file)
|
||||||
|
|
||||||
|
# Security: restrict to data directory (anchored to app root, not CWD)
|
||||||
|
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||||
|
try:
|
||||||
|
resolved = input_path.resolve()
|
||||||
|
if not resolved.is_relative_to(allowed_base):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'input_file must be under the data/ directory'
|
||||||
|
}), 403
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid file path'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not input_path.is_file():
|
||||||
|
logger.warning("Test-decode file not found")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'File not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# Validate sample rate
|
||||||
|
sample_rate = data.get('sample_rate', 1000000)
|
||||||
|
try:
|
||||||
|
sample_rate = int(sample_rate)
|
||||||
|
if sample_rate < 1000 or sample_rate > 20000000:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid sample_rate (1000-20000000)'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not _weather_sat_queue.empty():
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Set callback — no on_complete needed (no SDR to release)
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
decoder.set_on_complete(None)
|
||||||
|
|
||||||
|
success = decoder.start_from_file(
|
||||||
|
satellite=satellite,
|
||||||
|
input_file=input_file,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
sat_info = WEATHER_SATELLITES[satellite]
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'satellite': satellite,
|
||||||
|
'frequency': sat_info['frequency'],
|
||||||
|
'mode': sat_info['mode'],
|
||||||
|
'source': 'file',
|
||||||
|
'input_file': str(input_file),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start file decode'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_capture():
|
||||||
|
"""Stop weather satellite capture.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
device_index = decoder.device_index
|
||||||
|
|
||||||
|
decoder.stop()
|
||||||
|
|
||||||
|
# Release SDR device
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images')
|
||||||
|
def list_images():
|
||||||
|
"""Get list of decoded weather satellite images.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
limit: Maximum number of images (default: all)
|
||||||
|
satellite: Filter by satellite key (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of decoded images.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
images = decoder.get_images()
|
||||||
|
|
||||||
|
# Filter by satellite if specified
|
||||||
|
satellite_filter = request.args.get('satellite')
|
||||||
|
if satellite_filter:
|
||||||
|
images = [img for img in images if img.satellite == satellite_filter]
|
||||||
|
|
||||||
|
# Apply limit
|
||||||
|
limit = request.args.get('limit', type=int)
|
||||||
|
if limit and limit > 0:
|
||||||
|
images = images[-limit:]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'images': [img.to_dict() for img in images],
|
||||||
|
'count': len(images),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images/<filename>')
|
||||||
|
def get_image(filename: str):
|
||||||
|
"""Serve a decoded weather satellite image file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image file or 404.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
# Security: only allow safe filenames
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
||||||
|
|
||||||
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||||
|
return send_file(image_path, mimetype=mimetype)
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||||
|
def delete_image(filename: str):
|
||||||
|
"""Delete a decoded image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if decoder.delete_image(filename):
|
||||||
|
return jsonify({'status': 'deleted', 'filename': filename})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||||
|
def delete_all_images():
|
||||||
|
"""Delete all decoded weather satellite images.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with count of deleted images.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
count = decoder.delete_all_images()
|
||||||
|
return jsonify({'status': 'ok', 'deleted': count})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/stream')
|
||||||
|
def stream_progress():
|
||||||
|
"""SSE stream of capture/decode progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE stream (text/event-stream)
|
||||||
|
"""
|
||||||
|
response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/passes')
|
||||||
|
def get_passes():
|
||||||
|
"""Get upcoming weather satellite passes for observer location.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
latitude: Observer latitude (required)
|
||||||
|
longitude: Observer longitude (required)
|
||||||
|
hours: Hours to predict ahead (default: 24, max: 72)
|
||||||
|
min_elevation: Minimum elevation in degrees (default: 15)
|
||||||
|
trajectory: Include az/el trajectory points (default: false)
|
||||||
|
ground_track: Include lat/lon ground track points (default: false)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with upcoming passes for all weather satellites.
|
||||||
|
"""
|
||||||
|
include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
|
||||||
|
include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
|
||||||
|
|
||||||
|
raw_lat = request.args.get('latitude')
|
||||||
|
raw_lon = request.args.get('longitude')
|
||||||
|
|
||||||
|
if raw_lat is None or raw_lon is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'latitude and longitude parameters required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = validate_latitude(raw_lat)
|
||||||
|
lon = validate_longitude(raw_lon)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning('Invalid coordinates in get_passes: %s', e)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||||
|
|
||||||
|
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
||||||
|
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.weather_sat_predict import predict_passes
|
||||||
|
|
||||||
|
all_passes = predict_passes(
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
hours=hours,
|
||||||
|
min_elevation=min_elevation,
|
||||||
|
include_trajectory=include_trajectory,
|
||||||
|
include_ground_track=include_ground_track,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'passes': all_passes,
|
||||||
|
'count': len(all_passes),
|
||||||
|
'observer': {'latitude': lat, 'longitude': lon},
|
||||||
|
'prediction_hours': hours,
|
||||||
|
'min_elevation': min_elevation,
|
||||||
|
})
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'skyfield library not installed'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error predicting passes: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Pass prediction failed'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Auto-Scheduler Endpoints
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduler_event_callback(event: dict) -> None:
|
||||||
|
"""Forward scheduler events to the SSE queue."""
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
_weather_sat_queue.put_nowait(event)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/enable', methods=['POST'])
|
||||||
|
def enable_schedule():
|
||||||
|
"""Enable auto-scheduling of weather satellite captures.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"latitude": 51.5, // Required
|
||||||
|
"longitude": -0.1, // Required
|
||||||
|
"min_elevation": 15, // Minimum pass elevation (default: 15)
|
||||||
|
"device": 0, // RTL-SDR device index (default: 0)
|
||||||
|
"gain": 40.0, // SDR gain (default: 40)
|
||||||
|
"bias_t": false // Enable bias-T (default: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with scheduler status.
|
||||||
|
"""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if data.get('latitude') is None or data.get('longitude') is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'latitude and longitude required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = validate_latitude(data.get('latitude'))
|
||||||
|
lon = validate_longitude(data.get('longitude'))
|
||||||
|
min_elev = validate_elevation(data.get('min_elevation', 15))
|
||||||
|
device = validate_device_index(data.get('device', 0))
|
||||||
|
gain_val = validate_gain(data.get('gain', 40.0))
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning('Invalid parameter in enable_schedule: %s', e)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid parameter value'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||||
|
|
||||||
|
result = scheduler.enable(
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
min_elevation=min_elev,
|
||||||
|
device=device,
|
||||||
|
gain=gain_val,
|
||||||
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', **result})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||||
|
def disable_schedule():
|
||||||
|
"""Disable auto-scheduling."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
result = scheduler.disable()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/status')
|
||||||
|
def schedule_status():
|
||||||
|
"""Get current scheduler state."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
return jsonify(scheduler.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/passes')
|
||||||
|
def schedule_passes():
|
||||||
|
"""List scheduled passes."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
passes = scheduler.get_passes()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'passes': passes,
|
||||||
|
'count': len(passes),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/skip/<pass_id>', methods=['POST'])
|
||||||
|
def skip_pass(pass_id: str):
|
||||||
|
"""Skip a scheduled pass."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
if scheduler.skip_pass(pass_id):
|
||||||
|
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
|
||||||
@@ -137,6 +137,14 @@ need_sudo() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refresh sudo credential cache so long-running builds don't trigger
|
||||||
|
# mid-compilation password prompts (which can fail due to TTY issues
|
||||||
|
# inside subshells). Safe to call multiple times.
|
||||||
|
refresh_sudo() {
|
||||||
|
[[ -z "${SUDO:-}" ]] && return 0
|
||||||
|
sudo -v 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
detect_os() {
|
detect_os() {
|
||||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||||
OS="macos"
|
OS="macos"
|
||||||
@@ -214,9 +222,12 @@ check_tools() {
|
|||||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||||
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
|
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 "dump1090" "ADS-B decoder" dump1090
|
||||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
|
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||||
echo
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
check_required "gpsd" "GPS daemon" gpsd
|
check_required "gpsd" "GPS daemon" gpsd
|
||||||
@@ -385,7 +396,7 @@ install_rtlamr_from_source() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
else
|
else
|
||||||
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
@@ -427,7 +438,8 @@ install_multimon_ng_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
else
|
else
|
||||||
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
fi
|
fi
|
||||||
ok "multimon-ng installed successfully from source"
|
ok "multimon-ng installed successfully from source"
|
||||||
)
|
)
|
||||||
@@ -468,7 +480,8 @@ install_dsd_from_source() {
|
|||||||
if [[ -w /usr/local/lib ]]; then
|
if [[ -w /usr/local/lib ]]; then
|
||||||
make install >/dev/null 2>&1
|
make install >/dev/null 2>&1
|
||||||
else
|
else
|
||||||
sudo make install >/dev/null 2>&1
|
refresh_sudo
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO make install >/dev/null 2>&1
|
$SUDO make install >/dev/null 2>&1
|
||||||
@@ -504,7 +517,8 @@ install_dsd_from_source() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
else
|
else
|
||||||
sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO make install >/dev/null 2>&1 \
|
$SUDO make install >/dev/null 2>&1 \
|
||||||
@@ -542,7 +556,8 @@ install_dump1090_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 dump1090 /usr/local/bin/dump1090
|
install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
else
|
else
|
||||||
sudo install -m 0755 dump1090 /usr/local/bin/dump1090
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
fi
|
fi
|
||||||
ok "dump1090 installed successfully from source"
|
ok "dump1090 installed successfully from source"
|
||||||
else
|
else
|
||||||
@@ -568,18 +583,54 @@ install_acarsdec_from_source_macos() {
|
|||||||
|| { warn "Failed to clone acarsdec"; exit 1; }
|
|| { warn "Failed to clone acarsdec"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/acarsdec"
|
cd "$tmp_dir/acarsdec"
|
||||||
|
|
||||||
|
# Fix compiler flags for macOS Apple Silicon (ARM64)
|
||||||
|
# -march=native can fail with Apple Clang on M-series chips
|
||||||
|
# -Ofast is deprecated in modern Clang
|
||||||
|
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||||
|
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
|
||||||
|
info "Patched compiler flags for Apple Silicon (arm64)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fix pthread_tryjoin_np (Linux-only GNU extension) for macOS
|
||||||
|
# Replace with pthread_join which provides equivalent behavior
|
||||||
|
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
||||||
|
sed -i '' 's/pthread_tryjoin_np(\([^,]*\), NULL)/pthread_join(\1, NULL)/g' rtl.c
|
||||||
|
info "Patched pthread_tryjoin_np for macOS compatibility"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fix libacars linking on macOS (upstream issue #112)
|
||||||
|
# Use LIBACARS_LINK_LIBRARIES (full path) instead of LIBACARS_LIBRARIES (name only)
|
||||||
|
if grep -q 'LIBACARS_LIBRARIES' CMakeLists.txt 2>/dev/null; then
|
||||||
|
sed -i '' 's/${LIBACARS_LIBRARIES}/${LIBACARS_LINK_LIBRARIES}/g' CMakeLists.txt
|
||||||
|
info "Patched libacars linking for macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p build && cd build
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
# Set Homebrew paths for Apple Silicon (/opt/homebrew) or Intel (/usr/local)
|
||||||
|
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||||
|
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||||
|
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
|
||||||
|
|
||||||
info "Compiling acarsdec..."
|
info "Compiling acarsdec..."
|
||||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
build_log="$tmp_dir/acarsdec-build.log"
|
||||||
|
if cmake .. -Drtl=ON \
|
||||||
|
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||||
|
>"$build_log" 2>&1 \
|
||||||
|
&& make >>"$build_log" 2>&1; then
|
||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
else
|
else
|
||||||
sudo install -m 0755 acarsdec /usr/local/bin/acarsdec
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
fi
|
fi
|
||||||
ok "acarsdec installed successfully from source"
|
ok "acarsdec installed successfully from source"
|
||||||
else
|
else
|
||||||
warn "Failed to build acarsdec. ACARS decoding will not be available."
|
warn "Failed to build acarsdec. ACARS decoding will not be available."
|
||||||
|
warn "Build log (last 30 lines):"
|
||||||
|
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -608,7 +659,8 @@ install_aiscatcher_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||||
else
|
else
|
||||||
sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||||
fi
|
fi
|
||||||
ok "AIS-catcher installed successfully from source"
|
ok "AIS-catcher installed successfully from source"
|
||||||
else
|
else
|
||||||
@@ -617,8 +669,138 @@ install_aiscatcher_from_source_macos() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_satdump_from_source_debian() {
|
||||||
|
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake pkg-config \
|
||||||
|
libpng-dev libtiff-dev libjemalloc-dev libvolk-dev libnng-dev \
|
||||||
|
libzstd-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev \
|
||||||
|
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning SatDump v1.2.2..."
|
||||||
|
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/SatDump"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Ensure plugins are in the expected path (handles multiarch differences)
|
||||||
|
$SUDO mkdir -p /usr/local/lib/satdump/plugins
|
||||||
|
if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then
|
||||||
|
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do
|
||||||
|
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then
|
||||||
|
$SUDO ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
install_satdump_from_source_macos() {
|
||||||
|
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||||
|
|
||||||
|
brew_install cmake
|
||||||
|
brew_install libpng
|
||||||
|
brew_install libtiff
|
||||||
|
brew_install jemalloc
|
||||||
|
brew_install libvolk
|
||||||
|
brew_install nng
|
||||||
|
brew_install zstd
|
||||||
|
brew_install soapysdr
|
||||||
|
brew_install hackrf
|
||||||
|
brew_install fftw
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning SatDump v1.2.2..."
|
||||||
|
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/SatDump"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
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
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=18
|
need_sudo
|
||||||
|
|
||||||
|
# Prime sudo credentials upfront so builds don't prompt mid-compilation
|
||||||
|
if [[ -n "${SUDO:-}" ]]; then
|
||||||
|
info "Some tools require sudo to install. You may be prompted for your password."
|
||||||
|
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOTAL_STEPS=19
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -660,6 +842,9 @@ install_macos_packages() {
|
|||||||
progress "Installing rtl_433"
|
progress "Installing rtl_433"
|
||||||
brew_install rtl_433
|
brew_install rtl_433
|
||||||
|
|
||||||
|
progress "Installing HackRF tools"
|
||||||
|
brew_install hackrf
|
||||||
|
|
||||||
progress "Installing rtlamr (optional)"
|
progress "Installing rtlamr (optional)"
|
||||||
# rtlamr is optional - used for utility meter monitoring
|
# rtlamr is optional - used for utility meter monitoring
|
||||||
if ! cmd_exists rtlamr; then
|
if ! cmd_exists rtlamr; then
|
||||||
@@ -695,6 +880,19 @@ install_macos_packages() {
|
|||||||
ok "AIS-catcher already installed"
|
ok "AIS-catcher already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
progress "Installing SatDump (optional)"
|
||||||
|
if ! cmd_exists satdump; then
|
||||||
|
echo
|
||||||
|
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||||
|
if ask_yes_no "Do you want to install SatDump?"; then
|
||||||
|
install_satdump_from_source_macos || warn "SatDump build failed. Weather satellite decoding will not be available."
|
||||||
|
else
|
||||||
|
warn "Skipping SatDump installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "SatDump already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing aircrack-ng"
|
progress "Installing aircrack-ng"
|
||||||
brew_install aircrack-ng
|
brew_install aircrack-ng
|
||||||
|
|
||||||
@@ -992,7 +1190,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=25
|
TOTAL_STEPS=26
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1070,6 +1268,9 @@ install_debian_packages() {
|
|||||||
progress "Installing rtl_433"
|
progress "Installing rtl_433"
|
||||||
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
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)"
|
progress "Installing rtlamr (optional)"
|
||||||
# rtlamr is optional - used for utility meter monitoring
|
# rtlamr is optional - used for utility meter monitoring
|
||||||
if ! cmd_exists rtlamr; then
|
if ! cmd_exists rtlamr; then
|
||||||
@@ -1149,6 +1350,19 @@ install_debian_packages() {
|
|||||||
ok "AIS-catcher already installed"
|
ok "AIS-catcher already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
progress "Installing SatDump (optional)"
|
||||||
|
if ! cmd_exists satdump; then
|
||||||
|
echo
|
||||||
|
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||||
|
if ask_yes_no "Do you want to install SatDump?"; then
|
||||||
|
install_satdump_from_source_debian || warn "SatDump build failed. Weather satellite decoding will not be available."
|
||||||
|
else
|
||||||
|
warn "Skipping SatDump installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "SatDump already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Configuring udev rules"
|
progress "Configuring udev rules"
|
||||||
setup_udev_rules_debian
|
setup_udev_rules_debian
|
||||||
|
|
||||||
|
|||||||
+248
-11
@@ -1448,6 +1448,7 @@ header h1 .tagline {
|
|||||||
height: calc(100dvh - 96px);
|
height: calc(100dvh - 96px);
|
||||||
height: calc(100vh - 96px); /* Fallback */
|
height: calc(100vh - 96px); /* Fallback */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -1457,6 +1458,18 @@ header h1 .tagline {
|
|||||||
height: calc(100dvh - 96px);
|
height: calc(100dvh - 96px);
|
||||||
height: calc(100vh - 96px); /* Fallback */
|
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 {
|
.sidebar {
|
||||||
@@ -1480,6 +1493,63 @@ header h1 .tagline {
|
|||||||
display: none;
|
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 {
|
.section {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -1528,8 +1598,10 @@ header h1 .tagline {
|
|||||||
|
|
||||||
.section.collapsed h3 {
|
.section.collapsed h3 {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0 !important;
|
||||||
padding-bottom: 10px;
|
min-height: 0 !important;
|
||||||
|
padding-top: 10px !important;
|
||||||
|
padding-bottom: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section.collapsed h3::after {
|
.section.collapsed h3::after {
|
||||||
@@ -1538,7 +1610,8 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section.collapsed {
|
.section.collapsed {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0 !important;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section.collapsed>*:not(h3) {
|
.section.collapsed>*:not(h3) {
|
||||||
@@ -2313,6 +2386,45 @@ header h1 .tagline {
|
|||||||
display: block;
|
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 (ADS-B) Styles */
|
||||||
.aircraft-card {
|
.aircraft-card {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -4007,7 +4119,7 @@ header h1 .tagline {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 140px;
|
max-height: 340px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4028,7 +4140,9 @@ header h1 .tagline {
|
|||||||
|
|
||||||
.bt-detail-body {
|
.bt-detail-body {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
height: calc(100% - 30px);
|
height: auto;
|
||||||
|
max-height: calc(100% - 30px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-placeholder {
|
.bt-detail-placeholder {
|
||||||
@@ -4207,6 +4321,110 @@ header h1 .tagline {
|
|||||||
color: #9fffd1;
|
color: #9fffd1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Service Data Inspector */
|
||||||
|
.bt-detail-service-inspector {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-toggle {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-toggle:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-arrow.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAC Cluster Badge */
|
||||||
|
.bt-mac-cluster-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Behavioral Flag Badges */
|
||||||
|
.bt-flag-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 3px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-flag-badge.persistent {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-flag-badge.beacon-like {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-flag-badge.strong-stable {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Selected device highlight */
|
/* Selected device highlight */
|
||||||
.bt-device-row.selected {
|
.bt-device-row.selected {
|
||||||
background: rgba(0, 212, 255, 0.1);
|
background: rgba(0, 212, 255, 0.1);
|
||||||
@@ -4357,14 +4575,15 @@ header h1 .tagline {
|
|||||||
.bt-row-main {
|
.bt-row-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-row-left {
|
.bt-row-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 8px;
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -4409,13 +4628,25 @@ header h1 .tagline {
|
|||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-irk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
background: rgba(168, 85, 247, 0.15);
|
||||||
|
color: #a855f7;
|
||||||
|
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.bt-device-name {
|
.bt-device-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
overflow-wrap: break-word;
|
||||||
overflow: hidden;
|
word-break: break-word;
|
||||||
text-overflow: ellipsis;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-rssi-container {
|
.bt-rssi-container {
|
||||||
@@ -4472,6 +4703,12 @@ header h1 .tagline {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 4px 4px 0 42px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bluetooth Device Modal */
|
/* Bluetooth Device Modal */
|
||||||
.bt-modal-overlay {
|
.bt-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -326,3 +326,50 @@
|
|||||||
.aprs-meter-status.no-signal {
|
.aprs-meter-status.no-signal {
|
||||||
color: var(--accent-yellow);
|
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,337 @@
|
|||||||
|
/* GPS Mode Styles */
|
||||||
|
|
||||||
|
/* Sidebar info grid */
|
||||||
|
.gps-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-info-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-info-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status */
|
||||||
|
.gps-connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot.connected {
|
||||||
|
background: #00ff88;
|
||||||
|
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot.waiting {
|
||||||
|
background: #ffaa00;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-dot.error {
|
||||||
|
background: #ff4444;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-status-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix badge */
|
||||||
|
.gps-fix-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-fix-badge.no-fix {
|
||||||
|
background: rgba(255, 68, 68, 0.2);
|
||||||
|
color: #ff4444;
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-fix-badge.fix-2d {
|
||||||
|
background: rgba(255, 170, 0, 0.2);
|
||||||
|
color: #ffaa00;
|
||||||
|
border: 1px solid rgba(255, 170, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-fix-badge.fix-3d {
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
color: #00ff88;
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOP quality indicators */
|
||||||
|
.gps-dop-good { color: #00ff88; }
|
||||||
|
.gps-dop-moderate { color: #ffaa00; }
|
||||||
|
.gps-dop-poor { color: #ff4444; }
|
||||||
|
|
||||||
|
/* ===== Visuals Panel ===== */
|
||||||
|
|
||||||
|
.gps-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top row: sky view + position info */
|
||||||
|
.gps-visuals-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sky View */
|
||||||
|
.gps-skyview-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-skyview-panel h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-skyview-canvas-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gpsSkyCanvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position info panel */
|
||||||
|
.gps-position-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-position-panel h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-big {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal Strength Bars */
|
||||||
|
.gps-signal-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-panel h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
height: 140px;
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar {
|
||||||
|
width: 14px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar.unused {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-prn {
|
||||||
|
font-size: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-snr {
|
||||||
|
font-size: 7px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Constellation colors */
|
||||||
|
.gps-const-gps { background-color: #00d4ff; }
|
||||||
|
.gps-const-glonass { background-color: #00ff88; }
|
||||||
|
.gps-const-galileo { background-color: #ff8800; }
|
||||||
|
.gps-const-beidou { background-color: #ff4466; }
|
||||||
|
.gps-const-sbas { background-color: #ffdd00; }
|
||||||
|
.gps-const-qzss { background-color: #cc66ff; }
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.gps-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.gps-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-empty-state svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-empty-state p {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 300px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gps-visuals-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-skyview-panel,
|
||||||
|
.gps-position-panel {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-big {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -340,7 +340,9 @@
|
|||||||
MODE VISIBILITY - Ensure sidebar shows when active
|
MODE VISIBILITY - Ensure sidebar shows when active
|
||||||
============================================ */
|
============================================ */
|
||||||
#spystationsMode.active {
|
#spystationsMode.active {
|
||||||
display: block !important;
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
MODE VISIBILITY
|
MODE VISIBILITY
|
||||||
============================================ */
|
============================================ */
|
||||||
#sstvGeneralMode.active {
|
#sstvGeneralMode.active {
|
||||||
display: block !important;
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
MODE VISIBILITY
|
MODE VISIBILITY
|
||||||
============================================ */
|
============================================ */
|
||||||
#sstvMode.active {
|
#sstvMode.active {
|
||||||
display: block !important;
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -114,7 +114,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: min(320px, 85vw);
|
width: min(360px, 100vw);
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
height: 100vh; /* Fallback */
|
height: 100vh; /* Fallback */
|
||||||
background: var(--bg-secondary, #0f1218);
|
background: var(--bg-secondary, #0f1218);
|
||||||
@@ -381,6 +381,33 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-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 should not clip content */
|
||||||
.container {
|
.container {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|||||||
+28
-4
@@ -24,7 +24,7 @@
|
|||||||
background: var(--bg-dark, #0a0a0f);
|
background: var(--bg-dark, #0a0a0f);
|
||||||
border: 1px solid var(--border-color, #1a1a2e);
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: 600px;
|
max-width: 900px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: calc(100vh - 80px);
|
max-height: calc(100vh - 80px);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -74,22 +74,28 @@
|
|||||||
|
|
||||||
/* Settings Tabs */
|
/* Settings Tabs */
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
gap: 4px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 16px;
|
padding: 12px 10px;
|
||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab:hover {
|
.settings-tab:hover {
|
||||||
@@ -474,6 +480,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.settings-tabs {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settings-modal.active {
|
.settings-modal.active {
|
||||||
padding: 20px 10px;
|
padding: 20px 10px;
|
||||||
@@ -483,6 +495,18 @@
|
|||||||
max-width: 100%;
|
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 {
|
.settings-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const ProximityRadar = (function() {
|
|||||||
let isHovered = false;
|
let isHovered = false;
|
||||||
let renderPending = false;
|
let renderPending = false;
|
||||||
let renderTimer = null;
|
let renderTimer = null;
|
||||||
|
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the radar component
|
* Initialize the radar component
|
||||||
@@ -119,6 +120,36 @@ const ProximityRadar = (function() {
|
|||||||
|
|
||||||
svg = container.querySelector('svg');
|
svg = container.querySelector('svg');
|
||||||
|
|
||||||
|
// Event delegation on the devices group (survives innerHTML rebuilds)
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('click', (e) => {
|
||||||
|
const deviceEl = e.target.closest('.radar-device');
|
||||||
|
if (!deviceEl) return;
|
||||||
|
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||||
|
if (onDeviceClick && deviceKey) {
|
||||||
|
// Lock out re-renders briefly so the DOM stays stable after click
|
||||||
|
interactionLockUntil = Date.now() + 500;
|
||||||
|
onDeviceClick(deviceKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseenter', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = true;
|
||||||
|
}
|
||||||
|
}, true); // capture phase so we catch enter on child elements
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseleave', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = false;
|
||||||
|
if (renderPending) {
|
||||||
|
renderPending = false;
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Add sweep animation
|
// Add sweep animation
|
||||||
animateSweep();
|
animateSweep();
|
||||||
}
|
}
|
||||||
@@ -165,8 +196,8 @@ const ProximityRadar = (function() {
|
|||||||
devices.set(device.device_key, device);
|
devices.set(device.device_key, device);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defer render while user is hovering to prevent DOM rebuild flicker
|
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||||
if (isHovered) {
|
if (isHovered || Date.now() < interactionLockUntil) {
|
||||||
renderPending = true;
|
renderPending = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,7 +260,7 @@ const ProximityRadar = (function() {
|
|||||||
style="cursor: pointer;">
|
style="cursor: pointer;">
|
||||||
<!-- Invisible hit area to prevent hover flicker -->
|
<!-- Invisible hit area to prevent hover flicker -->
|
||||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||||
</circle>` : ''}
|
</circle>` : ''}
|
||||||
@@ -244,24 +275,6 @@ const ProximityRadar = (function() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
devicesGroup.innerHTML = dots;
|
devicesGroup.innerHTML = dots;
|
||||||
|
|
||||||
// Attach event handlers
|
|
||||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
|
||||||
el.addEventListener('click', (e) => {
|
|
||||||
const deviceKey = el.getAttribute('data-device-key');
|
|
||||||
if (onDeviceClick && deviceKey) {
|
|
||||||
onDeviceClick(deviceKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.addEventListener('mouseenter', () => { isHovered = true; });
|
|
||||||
el.addEventListener('mouseleave', () => {
|
|
||||||
isHovered = false;
|
|
||||||
if (renderPending) {
|
|
||||||
renderPending = false;
|
|
||||||
renderDevices();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -345,19 +358,125 @@ const ProximityRadar = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight a specific device on the radar
|
* Highlight a specific device on the radar (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function highlightDevice(deviceKey) {
|
function highlightDevice(deviceKey) {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = deviceKey;
|
selectedDeviceKey = deviceKey;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
// Remove highlight from previously selected node
|
||||||
|
if (prev && prev !== deviceKey) {
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
// Remove animated selection ring
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
// Restore dot opacity
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add highlight to newly selected node
|
||||||
|
if (deviceKey) {
|
||||||
|
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
|
||||||
|
if (newEl) {
|
||||||
|
applySelectionToElement(newEl, deviceKey);
|
||||||
|
} else {
|
||||||
|
// Node not in DOM yet; full render needed on next cycle
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device highlighting
|
* Apply selection styling to a radar device element in-place
|
||||||
|
*/
|
||||||
|
function applySelectionToElement(el, deviceKey) {
|
||||||
|
el.classList.add('selected');
|
||||||
|
const device = devices.get(deviceKey);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||||
|
|
||||||
|
// Update dot styling
|
||||||
|
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
dot.setAttribute('fill-opacity', '1');
|
||||||
|
dot.setAttribute('stroke', '#00d4ff');
|
||||||
|
dot.setAttribute('stroke-width', '2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animated selection ring if not already present
|
||||||
|
if (!el.querySelector('.radar-select-ring')) {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
const ring = document.createElementNS(ns, 'circle');
|
||||||
|
ring.classList.add('radar-select-ring');
|
||||||
|
ring.setAttribute('r', dotSize + 8);
|
||||||
|
ring.setAttribute('fill', 'none');
|
||||||
|
ring.setAttribute('stroke', '#00d4ff');
|
||||||
|
ring.setAttribute('stroke-width', '2');
|
||||||
|
ring.setAttribute('stroke-opacity', '0.8');
|
||||||
|
|
||||||
|
const animR = document.createElementNS(ns, 'animate');
|
||||||
|
animR.setAttribute('attributeName', 'r');
|
||||||
|
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
|
||||||
|
animR.setAttribute('dur', '1.5s');
|
||||||
|
animR.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animR);
|
||||||
|
|
||||||
|
const animO = document.createElementNS(ns, 'animate');
|
||||||
|
animO.setAttribute('attributeName', 'stroke-opacity');
|
||||||
|
animO.setAttribute('values', '0.8;0.4;0.8');
|
||||||
|
animO.setAttribute('dur', '1.5s');
|
||||||
|
animO.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animO);
|
||||||
|
|
||||||
|
// Insert after the hit area
|
||||||
|
const hitArea = el.querySelector('.radar-device-hitarea');
|
||||||
|
if (hitArea && hitArea.nextSibling) {
|
||||||
|
el.insertBefore(ring, hitArea.nextSibling);
|
||||||
|
} else {
|
||||||
|
el.insertBefore(ring, el.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear device highlighting (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function clearHighlight() {
|
function clearHighlight() {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = null;
|
selectedDeviceKey = null;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg || !prev) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -302,7 +302,13 @@ const SignalCards = (function() {
|
|||||||
*/
|
*/
|
||||||
function formatRelativeTime(timestamp) {
|
function formatRelativeTime(timestamp) {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
const date = new Date(timestamp);
|
let date = new Date(timestamp);
|
||||||
|
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
|
||||||
|
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
|
||||||
|
const today = new Date();
|
||||||
|
date = new Date(today.toDateString() + ' ' + timestamp);
|
||||||
|
}
|
||||||
|
if (isNaN(date.getTime())) return timestamp;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = Math.floor((now - date) / 1000);
|
const diff = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -488,10 +488,12 @@ function initApp() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collapse all sections by default (except SDR Device which is first)
|
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
|
||||||
document.querySelectorAll('.section').forEach((section, index) => {
|
document.querySelectorAll('.sidebar .section').forEach((section) => {
|
||||||
if (index > 0) {
|
if (section.querySelector('h3')) {
|
||||||
section.classList.add('collapsed');
|
section.classList.add('collapsed');
|
||||||
|
} else {
|
||||||
|
section.classList.remove('collapsed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Shared observer location helper for map-based modules.
|
// Shared observer location helper for map-based modules.
|
||||||
// Default: shared location enabled unless explicitly disabled via config.
|
// Default: shared location enabled unless explicitly disabled via config.
|
||||||
window.ObserverLocation = (function() {
|
window.ObserverLocation = (function() {
|
||||||
const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
|
const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
|
||||||
|
? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
|
||||||
|
: { lat: 51.5074, lon: -0.1278 };
|
||||||
const SHARED_KEY = 'observerLocation';
|
const SHARED_KEY = 'observerLocation';
|
||||||
const AIS_KEY = 'ais_observerLocation';
|
const AIS_KEY = 'ais_observerLocation';
|
||||||
const LEGACY_LAT_KEY = 'observerLat';
|
const LEGACY_LAT_KEY = 'observerLat';
|
||||||
@@ -41,6 +43,10 @@ window.ObserverLocation = (function() {
|
|||||||
return normalize(lat, lon);
|
return normalize(lat, lon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasStoredLocation() {
|
||||||
|
return !!(readKey(SHARED_KEY) || readKey(AIS_KEY) || readLegacyLatLon());
|
||||||
|
}
|
||||||
|
|
||||||
function getShared() {
|
function getShared() {
|
||||||
const current = readKey(SHARED_KEY);
|
const current = readKey(SHARED_KEY);
|
||||||
if (current) return current;
|
if (current) return current;
|
||||||
@@ -93,6 +99,7 @@ window.ObserverLocation = (function() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isSharedEnabled,
|
isSharedEnabled,
|
||||||
|
hasStoredLocation,
|
||||||
getShared,
|
getShared,
|
||||||
setShared,
|
setShared,
|
||||||
getForModule,
|
getForModule,
|
||||||
|
|||||||
+290
-78
@@ -356,7 +356,9 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
// Update panel elements
|
// Update panel elements
|
||||||
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
||||||
document.getElementById('btDetailAddress').textContent = device.address;
|
document.getElementById('btDetailAddress').textContent = isUuidAddress(device)
|
||||||
|
? 'CB: ' + device.address
|
||||||
|
: device.address;
|
||||||
|
|
||||||
// RSSI
|
// RSSI
|
||||||
const rssiEl = document.getElementById('btDetailRssi');
|
const rssiEl = document.getElementById('btDetailRssi');
|
||||||
@@ -366,10 +368,10 @@ const BluetoothMode = (function() {
|
|||||||
// Badges
|
// Badges
|
||||||
const badgesEl = document.getElementById('btDetailBadges');
|
const badgesEl = document.getElementById('btDetailBadges');
|
||||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
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>`;
|
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||||
if (device.seen_before) {
|
if (device.seen_before) {
|
||||||
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracker badge
|
// Tracker badge
|
||||||
if (device.is_tracker) {
|
if (device.is_tracker) {
|
||||||
@@ -451,14 +453,104 @@ const BluetoothMode = (function() {
|
|||||||
? minMax[0] + '/' + minMax[1]
|
? minMax[0] + '/' + minMax[1]
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||||
? new Date(device.first_seen).toLocaleTimeString()
|
? new Date(device.first_seen).toLocaleTimeString()
|
||||||
: '--';
|
: '--';
|
||||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||||
? new Date(device.last_seen).toLocaleTimeString()
|
? new Date(device.last_seen).toLocaleTimeString()
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
updateWatchlistButton(device);
|
// New stat cells
|
||||||
|
document.getElementById('btDetailTxPower').textContent = device.tx_power != null
|
||||||
|
? device.tx_power + ' dBm' : '--';
|
||||||
|
document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null
|
||||||
|
? device.seen_rate.toFixed(1) + '/min' : '--';
|
||||||
|
|
||||||
|
// Stability from variance
|
||||||
|
const stabilityEl = document.getElementById('btDetailStability');
|
||||||
|
if (device.rssi_variance != null) {
|
||||||
|
let stabLabel, stabColor;
|
||||||
|
if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; }
|
||||||
|
else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; }
|
||||||
|
else { stabLabel = 'Unstable'; stabColor = '#ef4444'; }
|
||||||
|
stabilityEl.textContent = stabLabel;
|
||||||
|
stabilityEl.style.color = stabColor;
|
||||||
|
} else {
|
||||||
|
stabilityEl.textContent = '--';
|
||||||
|
stabilityEl.style.color = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance with confidence
|
||||||
|
const distEl = document.getElementById('btDetailDistance');
|
||||||
|
if (device.estimated_distance_m != null) {
|
||||||
|
const confPct = Math.round((device.distance_confidence || 0) * 100);
|
||||||
|
distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%';
|
||||||
|
} else {
|
||||||
|
distEl.textContent = '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appearance badge
|
||||||
|
if (device.appearance_name) {
|
||||||
|
badgesHtml += '<span class="bt-detail-badge flag">' + escapeHtml(device.appearance_name) + '</span>';
|
||||||
|
badgesEl.innerHTML = badgesHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster indicator
|
||||||
|
const macClusterEl = document.getElementById('btDetailMacCluster');
|
||||||
|
if (macClusterEl) {
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
macClusterEl.textContent = device.mac_cluster_count + ' MACs';
|
||||||
|
macClusterEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
macClusterEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service data inspector
|
||||||
|
const inspectorEl = document.getElementById('btDetailServiceInspector');
|
||||||
|
const inspectorContent = document.getElementById('btInspectorContent');
|
||||||
|
if (inspectorEl && inspectorContent) {
|
||||||
|
const hasData = device.manufacturer_bytes || device.appearance != null ||
|
||||||
|
(device.service_data && Object.keys(device.service_data).length > 0);
|
||||||
|
if (hasData) {
|
||||||
|
inspectorEl.style.display = '';
|
||||||
|
let inspHtml = '';
|
||||||
|
if (device.appearance != null) {
|
||||||
|
const name = device.appearance_name || '';
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Appearance</span><span class="bt-inspector-value">0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.manufacturer_bytes) {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Mfr Data</span><span class="bt-inspector-value">' + escapeHtml(device.manufacturer_bytes) + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.service_data) {
|
||||||
|
Object.entries(device.service_data).forEach(([uuid, hex]) => {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">' + escapeHtml(uuid) + '</span><span class="bt-inspector-value">' + escapeHtml(hex) + '</span></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
inspectorContent.innerHTML = inspHtml;
|
||||||
|
} else {
|
||||||
|
inspectorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWatchlistButton(device);
|
||||||
|
|
||||||
|
// IRK
|
||||||
|
const irkContainer = document.getElementById('btDetailIrk');
|
||||||
|
if (irkContainer) {
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkContainer.style.display = 'block';
|
||||||
|
const irkVal = document.getElementById('btDetailIrkValue');
|
||||||
|
if (irkVal) {
|
||||||
|
const label = device.irk_source_name
|
||||||
|
? device.irk_source_name + ' — ' + device.irk_hex
|
||||||
|
: device.irk_hex;
|
||||||
|
irkVal.textContent = label;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
irkContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const servicesContainer = document.getElementById('btDetailServices');
|
const servicesContainer = document.getElementById('btDetailServices');
|
||||||
@@ -470,29 +562,29 @@ const BluetoothMode = (function() {
|
|||||||
servicesContainer.style.display = 'none';
|
servicesContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show content, hide placeholder
|
// Show content, hide placeholder
|
||||||
placeholder.style.display = 'none';
|
placeholder.style.display = 'none';
|
||||||
content.style.display = 'block';
|
content.style.display = 'block';
|
||||||
|
|
||||||
// Highlight selected device in list
|
// Highlight selected device in list
|
||||||
highlightSelectedDevice(deviceId);
|
highlightSelectedDevice(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update watchlist button state
|
* Update watchlist button state
|
||||||
*/
|
*/
|
||||||
function updateWatchlistButton(device) {
|
function updateWatchlistButton(device) {
|
||||||
const btn = document.getElementById('btDetailWatchBtn');
|
const btn = document.getElementById('btDetailWatchBtn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (typeof AlertCenter === 'undefined') {
|
if (typeof AlertCenter === 'undefined') {
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
btn.style.display = '';
|
btn.style.display = '';
|
||||||
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||||
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||||
btn.classList.toggle('active', watchlisted);
|
btn.classList.toggle('active', watchlisted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device selection
|
* Clear device selection
|
||||||
@@ -546,43 +638,43 @@ const BluetoothMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Copy selected device address to clipboard
|
* Copy selected device address to clipboard
|
||||||
*/
|
*/
|
||||||
function copyAddress() {
|
function copyAddress() {
|
||||||
if (!selectedDeviceId) return;
|
if (!selectedDeviceId) return;
|
||||||
const device = devices.get(selectedDeviceId);
|
const device = devices.get(selectedDeviceId);
|
||||||
if (!device) return;
|
if (!device) return;
|
||||||
|
|
||||||
navigator.clipboard.writeText(device.address).then(() => {
|
navigator.clipboard.writeText(device.address).then(() => {
|
||||||
const btn = document.getElementById('btDetailCopyBtn');
|
const btn = document.getElementById('btDetailCopyBtn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
const originalText = btn.textContent;
|
const originalText = btn.textContent;
|
||||||
btn.textContent = 'Copied!';
|
btn.textContent = 'Copied!';
|
||||||
btn.style.background = '#22c55e';
|
btn.style.background = '#22c55e';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
btn.style.background = '';
|
btn.style.background = '';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle Bluetooth watchlist for selected device
|
* Toggle Bluetooth watchlist for selected device
|
||||||
*/
|
*/
|
||||||
function toggleWatchlist() {
|
function toggleWatchlist() {
|
||||||
if (!selectedDeviceId) return;
|
if (!selectedDeviceId) return;
|
||||||
const device = devices.get(selectedDeviceId);
|
const device = devices.get(selectedDeviceId);
|
||||||
if (!device || typeof AlertCenter === 'undefined') return;
|
if (!device || typeof AlertCenter === 'undefined') return;
|
||||||
|
|
||||||
if (AlertCenter.isWatchlisted(device.address)) {
|
if (AlertCenter.isWatchlisted(device.address)) {
|
||||||
AlertCenter.removeBluetoothWatchlist(device.address);
|
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||||
showInfo('Removed from watchlist');
|
showInfo('Removed from watchlist');
|
||||||
} else {
|
} else {
|
||||||
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||||
showInfo('Added to watchlist');
|
showInfo('Added to watchlist');
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => updateWatchlistButton(device), 200);
|
setTimeout(() => updateWatchlistButton(device), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a device - opens modal with details
|
* Select a device - opens modal with details
|
||||||
@@ -600,9 +692,25 @@ const BluetoothMode = (function() {
|
|||||||
if (parts.length === 6) {
|
if (parts.length === 6) {
|
||||||
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
||||||
}
|
}
|
||||||
|
// CoreBluetooth UUID format (8-4-4-4-12)
|
||||||
|
if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) {
|
||||||
|
return address.substring(0, 8) + '...';
|
||||||
|
}
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUuidAddress(device) {
|
||||||
|
return device.address_type === 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(device) {
|
||||||
|
if (!device || !device.address) return '--';
|
||||||
|
if (isUuidAddress(device)) {
|
||||||
|
return device.address.substring(0, 8) + '-...' + device.address.slice(-4);
|
||||||
|
}
|
||||||
|
return device.address;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check system capabilities
|
* Check system capabilities
|
||||||
*/
|
*/
|
||||||
@@ -660,6 +768,12 @@ const BluetoothMode = (function() {
|
|||||||
hideCapabilityWarning();
|
hideCapabilityWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide Ubertooth option based on capabilities
|
||||||
|
const ubertoothOption = document.getElementById('btScanModeUbertooth');
|
||||||
|
if (ubertoothOption) {
|
||||||
|
ubertoothOption.style.display = data.has_ubertooth ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (scanModeSelect && data.preferred_backend) {
|
if (scanModeSelect && data.preferred_backend) {
|
||||||
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
||||||
if (option) option.selected = true;
|
if (option) option.selected = true;
|
||||||
@@ -1085,7 +1199,7 @@ const BluetoothMode = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
||||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' +
|
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
|
||||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
evidenceHtml +
|
evidenceHtml +
|
||||||
@@ -1130,11 +1244,11 @@ const BluetoothMode = (function() {
|
|||||||
const isNew = !inBaseline;
|
const isNew = !inBaseline;
|
||||||
const hasName = !!device.name;
|
const hasName = !!device.name;
|
||||||
const isTracker = device.is_tracker === true;
|
const isTracker = device.is_tracker === true;
|
||||||
const trackerType = device.tracker_type;
|
const trackerType = device.tracker_type;
|
||||||
const trackerConfidence = device.tracker_confidence;
|
const trackerConfidence = device.tracker_confidence;
|
||||||
const riskScore = device.risk_score || 0;
|
const riskScore = device.risk_score || 0;
|
||||||
const agentName = device._agent || 'Local';
|
const agentName = device._agent || 'Local';
|
||||||
const seenBefore = device.seen_before === true;
|
const seenBefore = device.seen_before === true;
|
||||||
|
|
||||||
// Calculate RSSI bar width (0-100%)
|
// Calculate RSSI bar width (0-100%)
|
||||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||||
@@ -1142,7 +1256,7 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
const displayName = device.name || formatDeviceId(device.address);
|
const displayName = device.name || formatDeviceId(device.address);
|
||||||
const name = escapeHtml(displayName);
|
const name = escapeHtml(displayName);
|
||||||
const addr = escapeHtml(device.address || 'Unknown');
|
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||||
const seenCount = device.seen_count || 0;
|
const seenCount = device.seen_count || 0;
|
||||||
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
||||||
@@ -1167,6 +1281,12 @@ const BluetoothMode = (function() {
|
|||||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRK badge - show if paired IRK is available
|
||||||
|
let irkBadge = '';
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkBadge = '<span class="bt-irk-badge">IRK</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Risk badge - show if risk score is significant
|
// Risk badge - show if risk score is significant
|
||||||
let riskBadge = '';
|
let riskBadge = '';
|
||||||
if (riskScore >= 0.3) {
|
if (riskScore >= 0.3) {
|
||||||
@@ -1184,11 +1304,38 @@ const BluetoothMode = (function() {
|
|||||||
statusDot = '<span class="bt-status-dot known"></span>';
|
statusDot = '<span class="bt-status-dot known"></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distance display
|
||||||
|
const distM = device.estimated_distance_m;
|
||||||
|
let distStr = '';
|
||||||
|
if (distM != null) {
|
||||||
|
distStr = '~' + distM.toFixed(1) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavioral flag badges
|
||||||
|
const hFlags = device.heuristic_flags || [];
|
||||||
|
let flagBadges = '';
|
||||||
|
if (device.is_persistent || hFlags.includes('persistent')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||||
|
}
|
||||||
|
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||||
|
}
|
||||||
|
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster badge
|
||||||
|
let clusterBadge = '';
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Build secondary info line
|
// Build secondary info line
|
||||||
let secondaryParts = [addr];
|
let secondaryParts = [addr];
|
||||||
if (mfr) secondaryParts.push(mfr);
|
if (mfr) secondaryParts.push(mfr);
|
||||||
secondaryParts.push('Seen ' + seenCount + '×');
|
if (distStr) secondaryParts.push(distStr);
|
||||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
secondaryParts.push('Seen ' + seenCount + '×');
|
||||||
|
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||||
// Add agent name if not Local
|
// Add agent name if not Local
|
||||||
if (agentName !== 'Local') {
|
if (agentName !== 'Local') {
|
||||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||||
@@ -1205,7 +1352,10 @@ const BluetoothMode = (function() {
|
|||||||
protoBadge +
|
protoBadge +
|
||||||
'<span class="bt-device-name">' + name + '</span>' +
|
'<span class="bt-device-name">' + name + '</span>' +
|
||||||
trackerBadge +
|
trackerBadge +
|
||||||
|
irkBadge +
|
||||||
riskBadge +
|
riskBadge +
|
||||||
|
flagBadges +
|
||||||
|
clusterBadge +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-right">' +
|
'<div class="bt-row-right">' +
|
||||||
'<div class="bt-rssi-container">' +
|
'<div class="bt-rssi-container">' +
|
||||||
@@ -1216,6 +1366,11 @@ const BluetoothMode = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
||||||
|
'<div class="bt-row-actions">' +
|
||||||
|
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' +
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
|
||||||
|
'Locate</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,6 +1450,18 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the service data inspector panel
|
||||||
|
*/
|
||||||
|
function toggleServiceInspector() {
|
||||||
|
const content = document.getElementById('btInspectorContent');
|
||||||
|
const arrow = document.getElementById('btInspectorArrow');
|
||||||
|
if (!content) return;
|
||||||
|
const open = content.style.display === 'none';
|
||||||
|
content.style.display = open ? '' : 'none';
|
||||||
|
if (arrow) arrow.classList.toggle('open', open);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Agent Handling
|
// Agent Handling
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -1391,6 +1558,48 @@ const BluetoothMode = (function() {
|
|||||||
updateRadar();
|
updateRadar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand off a device to BT Locate mode by device_id lookup.
|
||||||
|
*/
|
||||||
|
function locateById(deviceId) {
|
||||||
|
console.log('[BT] locateById called with:', deviceId);
|
||||||
|
const device = devices.get(deviceId);
|
||||||
|
if (!device) {
|
||||||
|
console.warn('[BT] Device not found in map for id:', deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doLocateHandoff(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand off the currently selected device to BT Locate mode.
|
||||||
|
*/
|
||||||
|
function locateDevice() {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const device = devices.get(selectedDeviceId);
|
||||||
|
if (!device) return;
|
||||||
|
doLocateHandoff(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLocateHandoff(device) {
|
||||||
|
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
||||||
|
if (typeof BtLocate !== 'undefined') {
|
||||||
|
BtLocate.handoff({
|
||||||
|
device_id: device.device_id,
|
||||||
|
mac_address: device.address,
|
||||||
|
address_type: device.address_type || null,
|
||||||
|
irk_hex: device.irk_hex || null,
|
||||||
|
known_name: device.name || null,
|
||||||
|
known_manufacturer: device.manufacturer_name || null,
|
||||||
|
last_known_rssi: device.rssi_current,
|
||||||
|
tx_power: device.tx_power || null,
|
||||||
|
appearance_name: device.appearance_name || null,
|
||||||
|
fingerprint_id: device.fingerprint_id || null,
|
||||||
|
mac_cluster_count: device.mac_cluster_count || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
@@ -1400,10 +1609,13 @@ const BluetoothMode = (function() {
|
|||||||
setBaseline,
|
setBaseline,
|
||||||
clearBaseline,
|
clearBaseline,
|
||||||
exportData,
|
exportData,
|
||||||
selectDevice,
|
selectDevice,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
copyAddress,
|
copyAddress,
|
||||||
toggleWatchlist,
|
toggleWatchlist,
|
||||||
|
locateDevice,
|
||||||
|
locateById,
|
||||||
|
toggleServiceInspector,
|
||||||
|
|
||||||
// Agent handling
|
// Agent handling
|
||||||
handleAgentChange,
|
handleAgentChange,
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
/**
|
||||||
|
* BT Locate — Bluetooth SAR Device Location Mode
|
||||||
|
* GPS-tagged signal trail mapping with proximity audio alerts.
|
||||||
|
*/
|
||||||
|
const BtLocate = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let eventSource = null;
|
||||||
|
let map = null;
|
||||||
|
let mapMarkers = [];
|
||||||
|
let trailLine = null;
|
||||||
|
let rssiHistory = [];
|
||||||
|
const MAX_RSSI_POINTS = 60;
|
||||||
|
let chartCanvas = null;
|
||||||
|
let chartCtx = null;
|
||||||
|
let currentEnvironment = 'OUTDOOR';
|
||||||
|
let audioCtx = null;
|
||||||
|
let audioEnabled = false;
|
||||||
|
let beepTimer = null;
|
||||||
|
let initialized = false;
|
||||||
|
let handoffData = null;
|
||||||
|
let pollTimer = null;
|
||||||
|
let durationTimer = null;
|
||||||
|
let sessionStartedAt = null;
|
||||||
|
let lastDetectionCount = 0;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (initialized) {
|
||||||
|
// Re-invalidate map on re-entry and ensure tiles are present
|
||||||
|
if (map) {
|
||||||
|
setTimeout(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
// Re-apply user's tile layer if tiles were lost
|
||||||
|
let hasTiles = false;
|
||||||
|
map.eachLayer(layer => {
|
||||||
|
if (layer instanceof L.TileLayer) hasTiles = true;
|
||||||
|
});
|
||||||
|
if (!hasTiles && typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
|
Settings.createTileLayer().addTo(map);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
checkStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init map
|
||||||
|
const mapEl = document.getElementById('btLocateMap');
|
||||||
|
if (mapEl && typeof L !== 'undefined') {
|
||||||
|
map = L.map('btLocateMap', {
|
||||||
|
center: [0, 0],
|
||||||
|
zoom: 2,
|
||||||
|
zoomControl: true,
|
||||||
|
});
|
||||||
|
// Use tile provider from user settings
|
||||||
|
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
|
Settings.createTileLayer().addTo(map);
|
||||||
|
Settings.registerMap(map);
|
||||||
|
} else {
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© 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 || '';
|
||||||
|
const addrDisplay = formatAddr(addr);
|
||||||
|
targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment info
|
||||||
|
const envEl = document.getElementById('btLocateEnvInfo');
|
||||||
|
if (envEl) {
|
||||||
|
const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' };
|
||||||
|
envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPS status
|
||||||
|
const gpsEl = document.getElementById('btLocateGpsStatus');
|
||||||
|
if (gpsEl) {
|
||||||
|
const src = data.gps_source || 'none';
|
||||||
|
if (src === 'live') gpsEl.textContent = 'GPS: Live';
|
||||||
|
else if (src === 'manual') gpsEl.textContent = 'GPS: Manual';
|
||||||
|
else gpsEl.textContent = 'GPS: None';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last seen
|
||||||
|
const lastEl = document.getElementById('btLocateLastSeen');
|
||||||
|
if (lastEl) {
|
||||||
|
if (data.last_detection) {
|
||||||
|
const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000);
|
||||||
|
lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago');
|
||||||
|
} else {
|
||||||
|
lastEl.textContent = 'Last: --';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session start time (duration handled by 1s timer)
|
||||||
|
if (data.started_at && !sessionStartedAt) {
|
||||||
|
sessionStartedAt = new Date(data.started_at).getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetection(event) {
|
||||||
|
const d = event.data;
|
||||||
|
if (!d) return;
|
||||||
|
|
||||||
|
// Update proximity UI
|
||||||
|
const bandEl = document.getElementById('btLocateBand');
|
||||||
|
const distEl = document.getElementById('btLocateDistance');
|
||||||
|
const rssiEl = document.getElementById('btLocateRssi');
|
||||||
|
const rssiEmaEl = document.getElementById('btLocateRssiEma');
|
||||||
|
|
||||||
|
if (bandEl) {
|
||||||
|
bandEl.textContent = d.proximity_band;
|
||||||
|
bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase();
|
||||||
|
}
|
||||||
|
if (distEl) distEl.textContent = d.estimated_distance.toFixed(1);
|
||||||
|
if (rssiEl) rssiEl.textContent = d.rssi;
|
||||||
|
if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
|
||||||
|
|
||||||
|
// RSSI sparkline
|
||||||
|
rssiHistory.push(d.rssi);
|
||||||
|
if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
|
||||||
|
drawRssiChart();
|
||||||
|
|
||||||
|
// Map marker
|
||||||
|
if (d.lat != null && d.lon != null) {
|
||||||
|
addMapMarker(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||||
|
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||||
|
if (detCountEl) {
|
||||||
|
const cur = parseInt(detCountEl.textContent) || 0;
|
||||||
|
detCountEl.textContent = cur + 1;
|
||||||
|
}
|
||||||
|
if (gpsCountEl && d.lat != null) {
|
||||||
|
const cur = parseInt(gpsCountEl.textContent) || 0;
|
||||||
|
gpsCountEl.textContent = cur + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio
|
||||||
|
if (audioEnabled) playProximityTone(d.rssi);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(detections, gpsPoints) {
|
||||||
|
const detCountEl = document.getElementById('btLocateDetectionCount');
|
||||||
|
const gpsCountEl = document.getElementById('btLocateGpsCount');
|
||||||
|
if (detCountEl) detCountEl.textContent = detections || 0;
|
||||||
|
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMapMarker(point) {
|
||||||
|
if (!map || point.lat == null || point.lon == null) return;
|
||||||
|
|
||||||
|
const band = (point.proximity_band || 'FAR').toLowerCase();
|
||||||
|
const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
|
||||||
|
const sizes = { immediate: 8, near: 6, far: 5 };
|
||||||
|
const color = colors[band] || '#eab308';
|
||||||
|
const radius = sizes[band] || 5;
|
||||||
|
|
||||||
|
const marker = L.circleMarker([point.lat, point.lon], {
|
||||||
|
radius: radius,
|
||||||
|
fillColor: color,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 1,
|
||||||
|
opacity: 0.9,
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.bindPopup(
|
||||||
|
'<div style="font-family:monospace;font-size:11px;">' +
|
||||||
|
'<b>' + point.proximity_band + '</b><br>' +
|
||||||
|
'RSSI: ' + point.rssi + ' dBm<br>' +
|
||||||
|
'Distance: ~' + point.estimated_distance.toFixed(1) + ' m<br>' +
|
||||||
|
'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
mapMarkers.push(marker);
|
||||||
|
map.panTo([point.lat, point.lon]);
|
||||||
|
|
||||||
|
// Update trail line
|
||||||
|
const latlngs = mapMarkers.map(m => m.getLatLng());
|
||||||
|
if (trailLine) {
|
||||||
|
trailLine.setLatLngs(latlngs);
|
||||||
|
} else if (latlngs.length >= 2) {
|
||||||
|
trailLine = L.polyline(latlngs, {
|
||||||
|
color: 'rgba(0,255,136,0.5)',
|
||||||
|
weight: 2,
|
||||||
|
dashArray: '4 4',
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreTrail() {
|
||||||
|
fetch('/bt_locate/trail')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(trail => {
|
||||||
|
if (trail.gps_trail && trail.gps_trail.length > 0) {
|
||||||
|
clearMapMarkers();
|
||||||
|
trail.gps_trail.forEach(p => addMapMarker(p));
|
||||||
|
}
|
||||||
|
if (trail.trail && trail.trail.length > 0) {
|
||||||
|
// Restore RSSI history from trail
|
||||||
|
rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
|
||||||
|
drawRssiChart();
|
||||||
|
// Update HUD with latest detection
|
||||||
|
const latest = trail.trail[trail.trail.length - 1];
|
||||||
|
handleDetection({ data: latest });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMapMarkers() {
|
||||||
|
mapMarkers.forEach(m => map?.removeLayer(m));
|
||||||
|
mapMarkers = [];
|
||||||
|
if (trailLine) {
|
||||||
|
map?.removeLayer(trailLine);
|
||||||
|
trailLine = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRssiChart() {
|
||||||
|
if (!chartCtx || !chartCanvas) return;
|
||||||
|
|
||||||
|
const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16;
|
||||||
|
const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24;
|
||||||
|
chartCtx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
if (rssiHistory.length < 2) return;
|
||||||
|
|
||||||
|
// RSSI range: -100 to -20
|
||||||
|
const minR = -100, maxR = -20;
|
||||||
|
const range = maxR - minR;
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
chartCtx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||||
|
chartCtx.lineWidth = 1;
|
||||||
|
[-30, -50, -70, -90].forEach(v => {
|
||||||
|
const y = h - ((v - minR) / range) * h;
|
||||||
|
chartCtx.beginPath();
|
||||||
|
chartCtx.moveTo(0, y);
|
||||||
|
chartCtx.lineTo(w, y);
|
||||||
|
chartCtx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw RSSI line
|
||||||
|
const step = w / (MAX_RSSI_POINTS - 1);
|
||||||
|
chartCtx.beginPath();
|
||||||
|
chartCtx.strokeStyle = '#00ff88';
|
||||||
|
chartCtx.lineWidth = 2;
|
||||||
|
|
||||||
|
rssiHistory.forEach((rssi, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = h - ((rssi - minR) / range) * h;
|
||||||
|
if (i === 0) chartCtx.moveTo(x, y);
|
||||||
|
else chartCtx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
chartCtx.stroke();
|
||||||
|
|
||||||
|
// Fill under
|
||||||
|
const lastIdx = rssiHistory.length - 1;
|
||||||
|
chartCtx.lineTo(lastIdx * step, h);
|
||||||
|
chartCtx.lineTo(0, h);
|
||||||
|
chartCtx.closePath();
|
||||||
|
chartCtx.fillStyle = 'rgba(0,255,136,0.08)';
|
||||||
|
chartCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio proximity tone (Web Audio API)
|
||||||
|
function playTone(freq, duration) {
|
||||||
|
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||||
|
const osc = audioCtx.createOscillator();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(audioCtx.destination);
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.type = 'sine';
|
||||||
|
gain.gain.value = 0.2;
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(audioCtx.currentTime + duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playProximityTone(rssi) {
|
||||||
|
if (!audioCtx || audioCtx.state !== 'running') return;
|
||||||
|
// Stronger signal = higher pitch and shorter beep
|
||||||
|
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
|
||||||
|
const freq = 400 + strength * 800; // 400-1200 Hz
|
||||||
|
const duration = 0.06 + (1 - strength) * 0.12;
|
||||||
|
playTone(freq, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAudio() {
|
||||||
|
const cb = document.getElementById('btLocateAudioEnable');
|
||||||
|
audioEnabled = cb?.checked || false;
|
||||||
|
if (audioEnabled) {
|
||||||
|
// Create AudioContext on user gesture (required by browser policy)
|
||||||
|
if (!audioCtx) {
|
||||||
|
try {
|
||||||
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BtLocate] AudioContext creation failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resume must happen within a user gesture handler
|
||||||
|
const ctx = audioCtx;
|
||||||
|
ctx.resume().then(() => {
|
||||||
|
console.log('[BtLocate] AudioContext state:', ctx.state);
|
||||||
|
// Confirmation beep so user knows audio is working
|
||||||
|
playTone(600, 0.08);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stopAudio();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudio() {
|
||||||
|
audioEnabled = false;
|
||||||
|
const cb = document.getElementById('btLocateAudioEnable');
|
||||||
|
if (cb) cb.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnvironment(env) {
|
||||||
|
currentEnvironment = env;
|
||||||
|
document.querySelectorAll('.btl-env-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.env === env);
|
||||||
|
});
|
||||||
|
// Push to running session if active
|
||||||
|
fetch('/bt_locate/status').then(r => r.json()).then(data => {
|
||||||
|
if (data.active) {
|
||||||
|
fetch('/bt_locate/environment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ environment: env }),
|
||||||
|
}).then(r => r.json()).then(res => {
|
||||||
|
console.log('[BtLocate] Environment updated:', res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUuid(addr) {
|
||||||
|
return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddr(addr) {
|
||||||
|
if (!addr) return '';
|
||||||
|
if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4);
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handoff(deviceInfo) {
|
||||||
|
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||||
|
handoffData = deviceInfo;
|
||||||
|
|
||||||
|
// Populate fields
|
||||||
|
if (deviceInfo.mac_address) {
|
||||||
|
const macInput = document.getElementById('btLocateMac');
|
||||||
|
if (macInput) macInput.value = deviceInfo.mac_address;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show handoff card
|
||||||
|
const card = document.getElementById('btLocateHandoffCard');
|
||||||
|
const nameEl = document.getElementById('btLocateHandoffName');
|
||||||
|
const metaEl = document.getElementById('btLocateHandoffMeta');
|
||||||
|
if (card) card.style.display = '';
|
||||||
|
if (nameEl) nameEl.textContent = deviceInfo.known_name || formatAddr(deviceInfo.mac_address) || 'Unknown';
|
||||||
|
if (metaEl) {
|
||||||
|
const parts = [];
|
||||||
|
if (deviceInfo.mac_address) parts.push(formatAddr(deviceInfo.mac_address));
|
||||||
|
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
|
||||||
|
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
|
||||||
|
metaEl.textContent = parts.join(' \u00b7 ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill IRK if available from scanner
|
||||||
|
if (deviceInfo.irk_hex) {
|
||||||
|
const irkInput = document.getElementById('btLocateIrk');
|
||||||
|
if (irkInput) irkInput.value = deviceInfo.irk_hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to bt_locate mode
|
||||||
|
if (typeof switchMode === 'function') {
|
||||||
|
switchMode('bt_locate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHandoff() {
|
||||||
|
handoffData = null;
|
||||||
|
const card = document.getElementById('btLocateHandoffCard');
|
||||||
|
if (card) card.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPairedIrks() {
|
||||||
|
const picker = document.getElementById('btLocateIrkPicker');
|
||||||
|
const status = document.getElementById('btLocateIrkPickerStatus');
|
||||||
|
const list = document.getElementById('btLocateIrkPickerList');
|
||||||
|
const btn = document.getElementById('btLocateDetectIrkBtn');
|
||||||
|
if (!picker || !status || !list) return;
|
||||||
|
|
||||||
|
// Toggle off if already visible
|
||||||
|
if (picker.style.display !== 'none') {
|
||||||
|
picker.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.style.display = '';
|
||||||
|
list.innerHTML = '';
|
||||||
|
status.textContent = 'Scanning paired devices...';
|
||||||
|
status.style.display = '';
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
fetch('/bt_locate/paired_irks')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
const devices = data.devices || [];
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
status.textContent = 'No paired devices with IRKs found';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.style.display = 'none';
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
devices.forEach(dev => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'btl-irk-picker-item';
|
||||||
|
item.innerHTML =
|
||||||
|
'<div class="btl-irk-picker-name">' + (dev.name || 'Unknown Device') + '</div>' +
|
||||||
|
'<div class="btl-irk-picker-meta">' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '</div>';
|
||||||
|
item.addEventListener('click', function() {
|
||||||
|
selectPairedIrk(dev);
|
||||||
|
});
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
console.error('[BtLocate] Failed to fetch paired IRKs:', err);
|
||||||
|
status.textContent = 'Failed to read paired devices';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPairedIrk(dev) {
|
||||||
|
const irkInput = document.getElementById('btLocateIrk');
|
||||||
|
const nameInput = document.getElementById('btLocateNamePattern');
|
||||||
|
const picker = document.getElementById('btLocateIrkPicker');
|
||||||
|
|
||||||
|
if (irkInput) irkInput.value = dev.irk_hex;
|
||||||
|
if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name;
|
||||||
|
if (picker) picker.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTrail() {
|
||||||
|
fetch('/bt_locate/clear_trail', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
clearMapMarkers();
|
||||||
|
rssiHistory = [];
|
||||||
|
drawRssiChart();
|
||||||
|
updateStats(0, 0);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[BtLocate] Clear trail error:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateMap() {
|
||||||
|
if (map) map.invalidateSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
handoff,
|
||||||
|
clearHandoff,
|
||||||
|
setEnvironment,
|
||||||
|
toggleAudio,
|
||||||
|
clearTrail,
|
||||||
|
handleDetection,
|
||||||
|
invalidateMap,
|
||||||
|
fetchPairedIrks,
|
||||||
|
};
|
||||||
|
})();
|
||||||
+293
-2
@@ -11,6 +11,13 @@ let dmrSyncCount = 0;
|
|||||||
let dmrCallHistory = [];
|
let dmrCallHistory = [];
|
||||||
let dmrCurrentProtocol = '--';
|
let dmrCurrentProtocol = '--';
|
||||||
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
|
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
|
||||||
|
let dmrHasAudio = false;
|
||||||
|
|
||||||
|
// ============== BOOKMARKS ==============
|
||||||
|
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 ==============
|
// ============== SYNTHESIZER STATE ==============
|
||||||
let dmrSynthCanvas = null;
|
let dmrSynthCanvas = null;
|
||||||
@@ -38,9 +45,17 @@ function checkDmrTools() {
|
|||||||
const warningText = document.getElementById('dmrToolsWarningText');
|
const warningText = document.getElementById('dmrToolsWarningText');
|
||||||
if (!warning) return;
|
if (!warning) return;
|
||||||
|
|
||||||
|
const selectedType = (typeof getSelectedSDRType === 'function')
|
||||||
|
? getSelectedSDRType()
|
||||||
|
: 'rtlsdr';
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
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) {
|
if (missing.length > 0) {
|
||||||
warning.style.display = 'block';
|
warning.style.display = 'block';
|
||||||
@@ -48,6 +63,9 @@ function checkDmrTools() {
|
|||||||
} else {
|
} else {
|
||||||
warning.style.display = 'none';
|
warning.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update audio panel availability
|
||||||
|
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -61,6 +79,7 @@ function startDmr() {
|
|||||||
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
||||||
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
|
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
|
||||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
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.
|
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
|
||||||
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
||||||
@@ -70,10 +89,17 @@ function startDmr() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save settings to localStorage for persistence
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({
|
||||||
|
frequency, protocol, gain, ppm, relaxCrc
|
||||||
|
}));
|
||||||
|
} catch (e) { /* localStorage unavailable */ }
|
||||||
|
|
||||||
fetch('/dmr/start', {
|
fetch('/dmr/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -94,6 +120,10 @@ function startDmr() {
|
|||||||
if (typeof reserveDevice === 'function') {
|
if (typeof reserveDevice === 'function') {
|
||||||
reserveDevice(parseInt(device), dmrModeLabel);
|
reserveDevice(parseInt(device), dmrModeLabel);
|
||||||
}
|
}
|
||||||
|
// Start audio if available
|
||||||
|
dmrHasAudio = !!data.has_audio;
|
||||||
|
if (dmrHasAudio) startDmrAudio();
|
||||||
|
updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE');
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||||
}
|
}
|
||||||
@@ -122,6 +152,7 @@ function startDmr() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopDmr() {
|
function stopDmr() {
|
||||||
|
stopDmrAudio();
|
||||||
fetch('/dmr/stop', { method: 'POST' })
|
fetch('/dmr/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -131,6 +162,7 @@ function stopDmr() {
|
|||||||
dmrEventType = 'stopped';
|
dmrEventType = 'stopped';
|
||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
if (typeof releaseDevice === 'function') {
|
if (typeof releaseDevice === 'function') {
|
||||||
@@ -231,10 +263,12 @@ function handleDmrMessage(msg) {
|
|||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
} else if (msg.text === 'crashed') {
|
} else if (msg.text === 'crashed') {
|
||||||
isDmrRunning = false;
|
isDmrRunning = false;
|
||||||
|
stopDmrAudio();
|
||||||
updateDmrUI();
|
updateDmrUI();
|
||||||
dmrEventType = 'stopped';
|
dmrEventType = 'stopped';
|
||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
if (statusEl) statusEl.textContent = 'CRASHED';
|
if (statusEl) statusEl.textContent = 'CRASHED';
|
||||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
||||||
@@ -243,10 +277,12 @@ function handleDmrMessage(msg) {
|
|||||||
}
|
}
|
||||||
} else if (msg.text === 'stopped') {
|
} else if (msg.text === 'stopped') {
|
||||||
isDmrRunning = false;
|
isDmrRunning = false;
|
||||||
|
stopDmrAudio();
|
||||||
updateDmrUI();
|
updateDmrUI();
|
||||||
dmrEventType = 'stopped';
|
dmrEventType = 'stopped';
|
||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
}
|
}
|
||||||
@@ -519,6 +555,249 @@ function stopDmrSynthesizer() {
|
|||||||
|
|
||||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
window.addEventListener('resize', resizeDmrSynthesizer);
|
||||||
|
|
||||||
|
// ============== AUDIO ==============
|
||||||
|
|
||||||
|
function startDmrAudio() {
|
||||||
|
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||||
|
if (!audioPlayer) return;
|
||||||
|
const streamUrl = `/dmr/audio/stream?t=${Date.now()}`;
|
||||||
|
audioPlayer.src = streamUrl;
|
||||||
|
const volSlider = document.getElementById('dmrAudioVolume');
|
||||||
|
if (volSlider) audioPlayer.volume = volSlider.value / 100;
|
||||||
|
|
||||||
|
audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING');
|
||||||
|
audioPlayer.onerror = () => {
|
||||||
|
// Retry if decoder is still running (stream may have dropped)
|
||||||
|
if (isDmrRunning && dmrHasAudio) {
|
||||||
|
console.warn('[DMR] Audio stream error, retrying in 2s...');
|
||||||
|
updateDmrAudioStatus('RECONNECTING');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isDmrRunning && dmrHasAudio) startDmrAudio();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audioPlayer.play().catch(e => {
|
||||||
|
console.warn('[DMR] Audio autoplay blocked:', e);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Audio Ready', 'Click the page or interact to enable audio playback');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDmrAudio() {
|
||||||
|
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||||
|
if (audioPlayer) {
|
||||||
|
audioPlayer.pause();
|
||||||
|
audioPlayer.src = '';
|
||||||
|
}
|
||||||
|
dmrHasAudio = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDmrAudioVolume(value) {
|
||||||
|
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||||
|
if (audioPlayer) audioPlayer.volume = value / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDmrAudioStatus(status) {
|
||||||
|
const el = document.getElementById('dmrAudioStatus');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = status;
|
||||||
|
const colors = {
|
||||||
|
'OFF': 'var(--text-muted)',
|
||||||
|
'STREAMING': 'var(--accent-green)',
|
||||||
|
'ERROR': 'var(--accent-red)',
|
||||||
|
'UNAVAILABLE': 'var(--text-muted)',
|
||||||
|
};
|
||||||
|
el.style.color = colors[status] || 'var(--text-muted)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== SETTINGS PERSISTENCE ==============
|
||||||
|
|
||||||
|
function restoreDmrSettings() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(DMR_SETTINGS_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
const s = JSON.parse(saved);
|
||||||
|
const freqEl = document.getElementById('dmrFrequency');
|
||||||
|
const protoEl = document.getElementById('dmrProtocol');
|
||||||
|
const gainEl = document.getElementById('dmrGain');
|
||||||
|
const ppmEl = document.getElementById('dmrPPM');
|
||||||
|
const crcEl = document.getElementById('dmrRelaxCrc');
|
||||||
|
if (freqEl && s.frequency != null) freqEl.value = s.frequency;
|
||||||
|
if (protoEl && s.protocol) protoEl.value = s.protocol;
|
||||||
|
if (gainEl && s.gain != null) gainEl.value = s.gain;
|
||||||
|
if (ppmEl && s.ppm != null) ppmEl.value = s.ppm;
|
||||||
|
if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc;
|
||||||
|
} catch (e) { /* localStorage unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== BOOKMARKS ==============
|
||||||
|
|
||||||
|
function loadDmrBookmarks() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
|
renderDmrBookmarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDmrBookmarks() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
|
||||||
|
} 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');
|
||||||
|
if (!freqInput) return;
|
||||||
|
|
||||||
|
const freq = parseFloat(freqInput.value);
|
||||||
|
if (isNaN(freq) || freq <= 0) {
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Invalid Frequency', 'Enter a valid frequency');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto');
|
||||||
|
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
|
||||||
|
|
||||||
|
// Duplicate check
|
||||||
|
if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) {
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Duplicate', 'This frequency/protocol is already bookmarked');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() });
|
||||||
|
saveDmrBookmarks();
|
||||||
|
renderDmrBookmarks();
|
||||||
|
freqInput.value = '';
|
||||||
|
if (labelInput) labelInput.value = '';
|
||||||
|
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCurrentDmrFreqBookmark() {
|
||||||
|
const freqEl = document.getElementById('dmrFrequency');
|
||||||
|
const freqInput = document.getElementById('dmrBookmarkFreq');
|
||||||
|
if (freqEl && freqInput) {
|
||||||
|
freqInput.value = freqEl.value;
|
||||||
|
}
|
||||||
|
addDmrBookmark();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDmrBookmark(index) {
|
||||||
|
dmrBookmarks.splice(index, 1);
|
||||||
|
saveDmrBookmarks();
|
||||||
|
renderDmrBookmarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dmrQuickTune(freq, protocol) {
|
||||||
|
const freqEl = document.getElementById('dmrFrequency');
|
||||||
|
const protoEl = document.getElementById('dmrProtocol');
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ==============
|
// ============== STATUS SYNC ==============
|
||||||
|
|
||||||
function checkDmrStatus() {
|
function checkDmrStatus() {
|
||||||
@@ -552,6 +831,13 @@ function checkDmrStatus() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== INIT ==============
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
restoreDmrSettings();
|
||||||
|
loadDmrBookmarks();
|
||||||
|
});
|
||||||
|
|
||||||
// ============== EXPORTS ==============
|
// ============== EXPORTS ==============
|
||||||
|
|
||||||
window.startDmr = startDmr;
|
window.startDmr = startDmr;
|
||||||
@@ -559,3 +845,8 @@ window.stopDmr = stopDmr;
|
|||||||
window.checkDmrTools = checkDmrTools;
|
window.checkDmrTools = checkDmrTools;
|
||||||
window.checkDmrStatus = checkDmrStatus;
|
window.checkDmrStatus = checkDmrStatus;
|
||||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||||
|
window.setDmrAudioVolume = setDmrAudioVolume;
|
||||||
|
window.addDmrBookmark = addDmrBookmark;
|
||||||
|
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
|
||||||
|
window.removeDmrBookmark = removeDmrBookmark;
|
||||||
|
window.dmrQuickTune = dmrQuickTune;
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* GPS Mode
|
||||||
|
* Live GPS data display with satellite sky view, signal strength bars,
|
||||||
|
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GPS = (function() {
|
||||||
|
let connected = false;
|
||||||
|
let lastPosition = null;
|
||||||
|
let lastSky = null;
|
||||||
|
|
||||||
|
// Constellation color map
|
||||||
|
const CONST_COLORS = {
|
||||||
|
'GPS': '#00d4ff',
|
||||||
|
'GLONASS': '#00ff88',
|
||||||
|
'Galileo': '#ff8800',
|
||||||
|
'BeiDou': '#ff4466',
|
||||||
|
'SBAS': '#ffdd00',
|
||||||
|
'QZSS': '#cc66ff',
|
||||||
|
};
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
drawEmptySkyView();
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
updateConnectionUI(false, false, 'connecting');
|
||||||
|
fetch('/gps/auto-connect', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'connected') {
|
||||||
|
connected = true;
|
||||||
|
updateConnectionUI(true, data.has_fix);
|
||||||
|
if (data.position) {
|
||||||
|
lastPosition = data.position;
|
||||||
|
updatePositionUI(data.position);
|
||||||
|
}
|
||||||
|
if (data.sky) {
|
||||||
|
lastSky = data.sky;
|
||||||
|
updateSkyUI(data.sky);
|
||||||
|
}
|
||||||
|
subscribeToStream();
|
||||||
|
// Ensure the global GPS stream is running
|
||||||
|
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
||||||
|
startGpsStream();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connected = false;
|
||||||
|
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
connected = false;
|
||||||
|
updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
unsubscribeFromStream();
|
||||||
|
fetch('/gps/stop', { method: 'POST' })
|
||||||
|
.then(() => {
|
||||||
|
connected = false;
|
||||||
|
updateConnectionUI(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGpsStreamData(data) {
|
||||||
|
if (!connected) return;
|
||||||
|
if (data.type === 'position') {
|
||||||
|
lastPosition = data;
|
||||||
|
updatePositionUI(data);
|
||||||
|
updateConnectionUI(true, true);
|
||||||
|
} else if (data.type === 'sky') {
|
||||||
|
lastSky = data;
|
||||||
|
updateSkyUI(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToStream() {
|
||||||
|
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||||
|
if (typeof addGpsStreamSubscriber === 'function') {
|
||||||
|
addGpsStreamSubscriber(onGpsStreamData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeFromStream() {
|
||||||
|
if (typeof removeGpsStreamSubscriber === 'function') {
|
||||||
|
removeGpsStreamSubscriber(onGpsStreamData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// UI Updates
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function updateConnectionUI(isConnected, hasFix, state, message) {
|
||||||
|
const dot = document.getElementById('gpsStatusDot');
|
||||||
|
const text = document.getElementById('gpsStatusText');
|
||||||
|
const connectBtn = document.getElementById('gpsConnectBtn');
|
||||||
|
const disconnectBtn = document.getElementById('gpsDisconnectBtn');
|
||||||
|
const devicePath = document.getElementById('gpsDevicePath');
|
||||||
|
|
||||||
|
if (dot) {
|
||||||
|
dot.className = 'gps-status-dot';
|
||||||
|
if (state === 'connecting') dot.classList.add('waiting');
|
||||||
|
else if (state === 'error') dot.classList.add('error');
|
||||||
|
else if (isConnected && hasFix) dot.classList.add('connected');
|
||||||
|
else if (isConnected) dot.classList.add('waiting');
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
if (state === 'connecting') text.textContent = 'Connecting...';
|
||||||
|
else if (state === 'error') text.textContent = message || 'Connection failed';
|
||||||
|
else if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||||
|
else if (isConnected) text.textContent = 'Connected (No Fix)';
|
||||||
|
else text.textContent = 'Disconnected';
|
||||||
|
}
|
||||||
|
if (connectBtn) {
|
||||||
|
connectBtn.style.display = isConnected ? 'none' : '';
|
||||||
|
connectBtn.disabled = state === 'connecting';
|
||||||
|
}
|
||||||
|
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
||||||
|
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositionUI(pos) {
|
||||||
|
// Sidebar fields
|
||||||
|
setText('gpsLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||||
|
setText('gpsHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||||
|
setText('gpsClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||||
|
|
||||||
|
// Fix type
|
||||||
|
const fixEl = document.getElementById('gpsFixType');
|
||||||
|
if (fixEl) {
|
||||||
|
const fq = pos.fix_quality;
|
||||||
|
if (fq === 3) fixEl.innerHTML = '<span class="gps-fix-badge fix-3d">3D FIX</span>';
|
||||||
|
else if (fq === 2) fixEl.innerHTML = '<span class="gps-fix-badge fix-2d">2D FIX</span>';
|
||||||
|
else fixEl.innerHTML = '<span class="gps-fix-badge no-fix">NO FIX</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error estimates
|
||||||
|
const eph = (pos.epx != null && pos.epy != null) ? Math.sqrt(pos.epx * pos.epx + pos.epy * pos.epy) : null;
|
||||||
|
setText('gpsEph', eph != null ? eph.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---');
|
||||||
|
|
||||||
|
// GPS time
|
||||||
|
if (pos.timestamp) {
|
||||||
|
const t = new Date(pos.timestamp);
|
||||||
|
setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visuals: position panel
|
||||||
|
setText('gpsVisPosLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsVisPosLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
|
||||||
|
setText('gpsVisPosAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
|
||||||
|
setText('gpsVisPosSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
|
||||||
|
setText('gpsVisPosHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
|
||||||
|
setText('gpsVisPosClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
|
||||||
|
|
||||||
|
// Visuals: fix badge
|
||||||
|
const visFixEl = document.getElementById('gpsVisFixBadge');
|
||||||
|
if (visFixEl) {
|
||||||
|
const fq = pos.fix_quality;
|
||||||
|
if (fq === 3) { visFixEl.textContent = '3D FIX'; visFixEl.className = 'gps-fix-badge fix-3d'; }
|
||||||
|
else if (fq === 2) { visFixEl.textContent = '2D FIX'; visFixEl.className = 'gps-fix-badge fix-2d'; }
|
||||||
|
else { visFixEl.textContent = 'NO FIX'; visFixEl.className = 'gps-fix-badge no-fix'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visuals: GPS time
|
||||||
|
if (pos.timestamp) {
|
||||||
|
const t = new Date(pos.timestamp);
|
||||||
|
setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSkyUI(sky) {
|
||||||
|
// Sidebar sat counts
|
||||||
|
setText('gpsSatUsed', sky.usat != null ? sky.usat : '-');
|
||||||
|
setText('gpsSatTotal', sky.nsat != null ? sky.nsat : '-');
|
||||||
|
|
||||||
|
// DOP values
|
||||||
|
setDop('gpsHdop', sky.hdop);
|
||||||
|
setDop('gpsVdop', sky.vdop);
|
||||||
|
setDop('gpsPdop', sky.pdop);
|
||||||
|
setDop('gpsTdop', sky.tdop);
|
||||||
|
setDop('gpsGdop', sky.gdop);
|
||||||
|
|
||||||
|
// Visuals
|
||||||
|
drawSkyView(sky.satellites || []);
|
||||||
|
drawSignalBars(sky.satellites || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDop(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
if (val == null) { el.textContent = '---'; el.className = 'gps-info-value gps-mono'; return; }
|
||||||
|
el.textContent = val.toFixed(1);
|
||||||
|
let cls = 'gps-info-value gps-mono ';
|
||||||
|
if (val <= 2) cls += 'gps-dop-good';
|
||||||
|
else if (val <= 5) cls += 'gps-dop-moderate';
|
||||||
|
else cls += 'gps-dop-poor';
|
||||||
|
el.className = cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Sky View Polar Plot
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function drawEmptySkyView() {
|
||||||
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
drawSkyViewBase(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSkyView(satellites) {
|
||||||
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const r = Math.min(cx, cy) - 24;
|
||||||
|
|
||||||
|
drawSkyViewBase(canvas);
|
||||||
|
|
||||||
|
// Plot satellites
|
||||||
|
satellites.forEach(sat => {
|
||||||
|
if (sat.elevation == null || sat.azimuth == null) return;
|
||||||
|
|
||||||
|
const elRad = (90 - sat.elevation) / 90;
|
||||||
|
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
||||||
|
const px = cx + r * elRad * Math.cos(azRad);
|
||||||
|
const py = cy + r * elRad * Math.sin(azRad);
|
||||||
|
|
||||||
|
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||||
|
const dotSize = sat.used ? 6 : 4;
|
||||||
|
|
||||||
|
// Draw dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
|
if (sat.used) {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRN label
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = '8px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||||
|
|
||||||
|
// SNR value
|
||||||
|
if (sat.snr != null) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.font = '7px JetBrains Mono, monospace';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSkyViewBase(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const r = Math.min(cx, cy) - 24;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
const bgStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-card').trim();
|
||||||
|
ctx.fillStyle = bgStyle || '#0d1117';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Elevation rings (0, 30, 60, 90)
|
||||||
|
ctx.strokeStyle = '#2a3040';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
[90, 60, 30].forEach(el => {
|
||||||
|
const gr = r * (1 - el / 90);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#555';
|
||||||
|
ctx.font = '9px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizon circle
|
||||||
|
ctx.strokeStyle = '#3a4050';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Cardinal directions
|
||||||
|
ctx.fillStyle = '#888';
|
||||||
|
ctx.font = 'bold 11px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('N', cx, cy - r - 12);
|
||||||
|
ctx.fillText('S', cx, cy + r + 12);
|
||||||
|
ctx.fillText('E', cx + r + 12, cy);
|
||||||
|
ctx.fillText('W', cx - r - 12, cy);
|
||||||
|
|
||||||
|
// Crosshairs
|
||||||
|
ctx.strokeStyle = '#2a3040';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - r);
|
||||||
|
ctx.lineTo(cx, cy + r);
|
||||||
|
ctx.moveTo(cx - r, cy);
|
||||||
|
ctx.lineTo(cx + r, cy);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Zenith dot
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Signal Strength Bars
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function drawSignalBars(satellites) {
|
||||||
|
const container = document.getElementById('gpsSignalBars');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (satellites.length === 0) return;
|
||||||
|
|
||||||
|
// Sort: used first, then by PRN
|
||||||
|
const sorted = [...satellites].sort((a, b) => {
|
||||||
|
if (a.used !== b.used) return a.used ? -1 : 1;
|
||||||
|
return a.prn - b.prn;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxSnr = 50; // dB-Hz typical max for display
|
||||||
|
|
||||||
|
sorted.forEach(sat => {
|
||||||
|
const snr = sat.snr || 0;
|
||||||
|
const heightPct = Math.min(snr / maxSnr * 100, 100);
|
||||||
|
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
||||||
|
const constClass = 'gps-const-' + (sat.constellation || 'GPS').toLowerCase();
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'gps-signal-bar-wrap';
|
||||||
|
|
||||||
|
const snrLabel = document.createElement('span');
|
||||||
|
snrLabel.className = 'gps-signal-snr';
|
||||||
|
snrLabel.textContent = snr > 0 ? Math.round(snr) : '';
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'gps-signal-bar ' + constClass + (sat.used ? '' : ' unused');
|
||||||
|
bar.style.height = Math.max(heightPct, 2) + '%';
|
||||||
|
bar.title = `PRN ${sat.prn} (${sat.constellation}) - ${Math.round(snr)} dB-Hz${sat.used ? ' [USED]' : ''}`;
|
||||||
|
|
||||||
|
const prn = document.createElement('span');
|
||||||
|
prn.className = 'gps-signal-prn';
|
||||||
|
prn.textContent = sat.prn;
|
||||||
|
|
||||||
|
wrap.appendChild(snrLabel);
|
||||||
|
wrap.appendChild(bar);
|
||||||
|
wrap.appendChild(prn);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Cleanup
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
unsubscribeFromStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
connect: connect,
|
||||||
|
disconnect: disconnect,
|
||||||
|
destroy: destroy,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1742,9 +1742,6 @@ function initSynthesizer() {
|
|||||||
drawSynthesizer();
|
drawSynthesizer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: log signal level periodically
|
|
||||||
let lastSynthDebugLog = 0;
|
|
||||||
|
|
||||||
function drawSynthesizer() {
|
function drawSynthesizer() {
|
||||||
if (!synthCtx || !synthCanvas) return;
|
if (!synthCtx || !synthCanvas) return;
|
||||||
|
|
||||||
@@ -1760,19 +1757,6 @@ function drawSynthesizer() {
|
|||||||
let activityLevel = 0;
|
let activityLevel = 0;
|
||||||
let signalIntensity = 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) {
|
if (isScannerRunning && !isScannerPaused) {
|
||||||
// Use actual signal level data (0-5000 range, normalize to 0-1)
|
// Use actual signal level data (0-5000 range, normalize to 0-1)
|
||||||
signalIntensity = Math.min(1, currentSignalLevel / 3000);
|
signalIntensity = Math.min(1, currentSignalLevel / 3000);
|
||||||
@@ -1864,13 +1848,6 @@ function drawSynthesizer() {
|
|||||||
synthCtx.lineTo(width, height / 2);
|
synthCtx.lineTo(width, height / 2);
|
||||||
synthCtx.stroke();
|
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);
|
synthAnimationId = requestAnimationFrame(drawSynthesizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3109,7 +3086,7 @@ let waterfallEndFreq = 108;
|
|||||||
let waterfallRowImage = null;
|
let waterfallRowImage = null;
|
||||||
let waterfallPalette = null;
|
let waterfallPalette = null;
|
||||||
let lastWaterfallDraw = 0;
|
let lastWaterfallDraw = 0;
|
||||||
const WATERFALL_MIN_INTERVAL_MS = 50;
|
const WATERFALL_MIN_INTERVAL_MS = 200;
|
||||||
let waterfallInteractionBound = false;
|
let waterfallInteractionBound = false;
|
||||||
let waterfallResizeObserver = null;
|
let waterfallResizeObserver = null;
|
||||||
let waterfallMode = 'rf';
|
let waterfallMode = 'rf';
|
||||||
@@ -3296,7 +3273,7 @@ async function syncWaterfallToFrequency(freq, options = {}) {
|
|||||||
span_mhz: Math.max(0.1, ef - sf),
|
span_mhz: Math.max(0.1, ef - sf),
|
||||||
gain: g,
|
gain: g,
|
||||||
device: dev,
|
device: dev,
|
||||||
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
|
||||||
fft_size: fft,
|
fft_size: fft,
|
||||||
fps: 25,
|
fps: 25,
|
||||||
avg_count: 4,
|
avg_count: 4,
|
||||||
@@ -3341,7 +3318,7 @@ async function zoomWaterfall(direction) {
|
|||||||
span_mhz: Math.max(0.1, ef - sf),
|
span_mhz: Math.max(0.1, ef - sf),
|
||||||
gain: g,
|
gain: g,
|
||||||
device: dev,
|
device: dev,
|
||||||
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
|
||||||
fft_size: fft,
|
fft_size: fft,
|
||||||
fps: 25,
|
fps: 25,
|
||||||
avg_count: 4,
|
avg_count: 4,
|
||||||
@@ -3436,9 +3413,8 @@ function startAudioWaterfall() {
|
|||||||
if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) {
|
if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) {
|
||||||
lastAudioWaterfallDraw = ts;
|
lastAudioWaterfallDraw = ts;
|
||||||
visualizerAnalyser.getByteFrequencyData(dataArray);
|
visualizerAnalyser.getByteFrequencyData(dataArray);
|
||||||
const bins = Array.from(dataArray, v => v);
|
drawWaterfallRow(dataArray);
|
||||||
drawWaterfallRow(bins);
|
drawSpectrumLine(dataArray, 0, maxFreqKhz, 'kHz');
|
||||||
drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz');
|
|
||||||
}
|
}
|
||||||
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
|
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
|
||||||
};
|
};
|
||||||
@@ -3841,7 +3817,7 @@ async function startWaterfall(options = {}) {
|
|||||||
span_mhz: spanMhz,
|
span_mhz: spanMhz,
|
||||||
gain: gain,
|
gain: gain,
|
||||||
device: device,
|
device: device,
|
||||||
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
|
||||||
fft_size: fftSize,
|
fft_size: fftSize,
|
||||||
fps: 25,
|
fps: 25,
|
||||||
avg_count: 4,
|
avg_count: 4,
|
||||||
@@ -3943,11 +3919,31 @@ async function stopWaterfall() {
|
|||||||
|
|
||||||
// WebSocket path
|
// WebSocket path
|
||||||
if (waterfallUseWebSocket && waterfallWebSocket) {
|
if (waterfallUseWebSocket && waterfallWebSocket) {
|
||||||
|
const ws = waterfallWebSocket;
|
||||||
try {
|
try {
|
||||||
if (waterfallWebSocket.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' }));
|
// Wait for server to confirm stop (it terminates the IQ
|
||||||
|
// process and releases the USB device before responding).
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(resolve, 4000);
|
||||||
|
const prevHandler = ws.onmessage;
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.status === 'stopped') {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (prevHandler) prevHandler(event);
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify({ cmd: 'stop' }));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
waterfallWebSocket.close();
|
ws.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[WATERFALL] WebSocket stop error:', e);
|
console.error('[WATERFALL] WebSocket stop error:', e);
|
||||||
}
|
}
|
||||||
@@ -3958,8 +3954,6 @@ async function stopWaterfall() {
|
|||||||
if (typeof releaseDevice === 'function') {
|
if (typeof releaseDevice === 'function') {
|
||||||
releaseDevice('waterfall');
|
releaseDevice('waterfall');
|
||||||
}
|
}
|
||||||
// Allow backend WebSocket handler to finish cleanup and release SDR
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -223,6 +223,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide Panel -->
|
||||||
|
<div class="panel" id="antennaGuidePanel">
|
||||||
|
<div class="panel-header" style="cursor: pointer;" onclick="document.getElementById('antennaGuideContent').style.display = document.getElementById('antennaGuideContent').style.display === 'none' ? 'block' : 'none'; this.querySelector('.panel-toggle').textContent = document.getElementById('antennaGuideContent').style.display === 'none' ? '▶' : '▼';">
|
||||||
|
<span>ANTENNA GUIDE</span>
|
||||||
|
<span class="panel-toggle" style="font-size: 10px; color: var(--text-muted);">▶</span>
|
||||||
|
</div>
|
||||||
|
<div id="antennaGuideContent" style="display: none; padding: 10px; font-size: 11px; color: var(--text-secondary); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
1090 MHz — stock SDR antenna can work but is not ideal
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 11px;">Stock Telescopic Antenna</strong>
|
||||||
|
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||||
|
<li><strong style="color: var(--text-primary);">1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||||
|
<strong style="color: #00ff88; font-size: 11px;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
|
||||||
|
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Gain:</strong> ~5–7 dBi omnidirectional, ideal for 360° coverage</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Range:</strong> 150–250+ NM depending on height and LOS</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 11px;">Commercial Options</strong>
|
||||||
|
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||||
|
<li><strong style="color: var(--text-primary);">FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">ADSBexchange whip:</strong> ~$40, similar performance</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 11px;">Placement & LNA</strong>
|
||||||
|
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 11px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 4px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-dim);">ADS-B frequency</td>
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">1090 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">6.9 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">PPM (pulse)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">~2 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-dim);">Typical range (outdoor)</td>
|
||||||
|
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">100–250 NM</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls Bar - Reorganized -->
|
<!-- Controls Bar - Reorganized -->
|
||||||
@@ -2455,7 +2537,6 @@ sudo make install</code>
|
|||||||
// Check for remote dump1090 config (only for local mode)
|
// Check for remote dump1090 config (only for local mode)
|
||||||
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
|
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
|
||||||
if (remoteConfig === false) return;
|
if (remoteConfig === false) return;
|
||||||
|
|
||||||
// Check for agent SDR conflicts
|
// Check for agent SDR conflicts
|
||||||
if (useAgent && typeof checkAgentModeConflict === 'function') {
|
if (useAgent && typeof checkAgentModeConflict === 'function') {
|
||||||
if (!checkAgentModeConflict('adsb')) {
|
if (!checkAgentModeConflict('adsb')) {
|
||||||
@@ -2474,7 +2555,6 @@ sudo make install</code>
|
|||||||
requestBody.remote_sbs_host = remoteConfig.host;
|
requestBody.remote_sbs_host = remoteConfig.host;
|
||||||
requestBody.remote_sbs_port = remoteConfig.port;
|
requestBody.remote_sbs_port = remoteConfig.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Route through agent proxy if using remote agent
|
// Route through agent proxy if using remote agent
|
||||||
const url = useAgent
|
const url = useAgent
|
||||||
@@ -3789,7 +3869,9 @@ sudo make install</code>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startAcars() {
|
function startAcars() {
|
||||||
const device = document.getElementById('acarsDeviceSelect').value;
|
const acarsSelect = document.getElementById('acarsDeviceSelect');
|
||||||
|
const device = acarsSelect.value;
|
||||||
|
const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
|
||||||
const frequencies = getAcarsRegionFreqs();
|
const frequencies = getAcarsRegionFreqs();
|
||||||
|
|
||||||
// Check if using agent mode
|
// Check if using agent mode
|
||||||
@@ -3815,7 +3897,7 @@ sudo make install</code>
|
|||||||
fetch(endpoint, {
|
fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device, frequencies, gain: '40' })
|
body: JSON.stringify({ device, frequencies, gain: '40', sdr_type })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -4021,6 +4103,7 @@ sudo make install</code>
|
|||||||
devices.forEach((d, i) => {
|
devices.forEach((d, i) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = d.index || i;
|
opt.value = d.index || i;
|
||||||
|
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
|
||||||
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
|
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
@@ -4800,6 +4883,7 @@ sudo make install</code>
|
|||||||
devices.forEach(device => {
|
devices.forEach(device => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = device.index;
|
opt.value = device.index;
|
||||||
|
opt.dataset.sdrType = device.sdr_type || 'rtlsdr';
|
||||||
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
|
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|||||||
+930
-79
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
Marine VHF band (162 MHz) — stock SDR antenna will NOT work well
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Cheapest)</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~46 cm each (quarter-wave at 162 MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (AIS is vertically polarized)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Placement:</strong> As high as possible with clear view of the water/harbor</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Marine VHF whip:</strong> ~$20–50, designed for 156–163 MHz band</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30–50, wideband coverage including marine VHF</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Collinear:</strong> Higher gain (~6 dBi), best for coastal monitoring</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Height is critical:</strong> AIS is line-of-sight. Roof or mast mount is ideal</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Range:</strong> At 10m height, expect ~25 NM (46 km) range over water</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">LNA:</strong> Nooelec Lana or similar broadband LNA, mount at antenna</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep cable short. RG-58 loses ~4 dB per 10m at 162 MHz</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel A</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">161.975 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel B</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">162.025 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">46 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">GMSK 9600 baud</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
|
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
|
||||||
Start AIS Tracking
|
Start AIS Tracking
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,4 +13,59 @@
|
|||||||
<span style="color: var(--accent-cyan);">Controls in function bar above map</span>
|
<span style="color: var(--accent-cyan);">Controls in function bar above map</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
2m band (144–148 MHz) — stock SDR antenna will NOT work
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Easiest)</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~51.5 cm each (quarter-wave at 144.39 MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (APRS is FM, vertically polarized)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Connection:</strong> Center conductor to one element, shield to the other</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Mag-mount 2m whip:</strong> ~$15–25, good mobile/portable option</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">2m/70cm dual-band:</strong> ~$20–40, also covers 70cm ham band</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30–50, wideband but lower gain on 2m</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">APRS freq (N. America)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">144.390 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">APRS freq (Europe)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">144.800 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">51.5 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM 1200 baud</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
<!-- BLUETOOTH MODE -->
|
<!-- BLUETOOTH MODE -->
|
||||||
<div id="bluetoothMode" class="mode-content">
|
<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">
|
<div class="section">
|
||||||
<h3>Scanner Configuration</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label>Adapter</label>
|
<label>Adapter</label>
|
||||||
<select id="btAdapterSelect">
|
<select id="btAdapterSelect">
|
||||||
@@ -28,6 +26,7 @@
|
|||||||
<option value="bleak">Bleak Library</option>
|
<option value="bleak">Bleak Library</option>
|
||||||
<option value="hcitool">hcitool (Linux)</option>
|
<option value="hcitool">hcitool (Linux)</option>
|
||||||
<option value="bluetoothctl">bluetoothctl (Linux)</option>
|
<option value="bluetoothctl">bluetoothctl (Linux)</option>
|
||||||
|
<option value="ubertooth" id="btScanModeUbertooth" style="display:none;">Ubertooth One</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -61,7 +60,7 @@
|
|||||||
Stop Scanning
|
Stop Scanning
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="section" style="margin-top: 10px;">
|
<div class="section">
|
||||||
<h3>Export</h3>
|
<h3>Export</h3>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">
|
<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 id="dmrMode" class="mode-content">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Digital Voice</h3>
|
<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 -->
|
<!-- 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;">
|
<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">
|
<div class="form-group">
|
||||||
<label>Protocol</label>
|
<label>Protocol</label>
|
||||||
<select id="dmrProtocol">
|
<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="dmr">DMR</option>
|
||||||
<option value="p25">P25</option>
|
<option value="p25">P25</option>
|
||||||
<option value="nxdn">NXDN</option>
|
<option value="nxdn">NXDN</option>
|
||||||
<option value="dstar">D-STAR</option>
|
<option value="dstar">D-STAR</option>
|
||||||
<option value="provoice">ProVoice</option>
|
<option value="provoice">ProVoice</option>
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -50,13 +56,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Bookmarks -->
|
||||||
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
|
<div class="section" style="margin-top: 8px;">
|
||||||
Start Decoder
|
<h3>Bookmarks</h3>
|
||||||
</button>
|
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
||||||
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none; margin-top: 12px;">
|
<input type="number" id="dmrBookmarkFreq" placeholder="Freq MHz" step="0.0001"
|
||||||
Stop Decoder
|
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
||||||
</button>
|
<button class="preset-btn" onclick="addDmrBookmark()" style="font-size: 10px; padding: 4px 8px;"
|
||||||
|
title="Add bookmark">+</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
||||||
|
<input type="text" id="dmrBookmarkLabel" placeholder="Label (optional)"
|
||||||
|
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
||||||
|
<button class="preset-btn" onclick="addCurrentDmrFreqBookmark()" style="font-size: 9px; padding: 4px 6px;"
|
||||||
|
title="Save current frequency">Save current</button>
|
||||||
|
</div>
|
||||||
|
<div id="dmrBookmarksList" style="max-height: 150px; overflow-y: auto;">
|
||||||
|
<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current Call -->
|
<!-- Current Call -->
|
||||||
<div class="section" style="margin-top: 12px;">
|
<div class="section" style="margin-top: 12px;">
|
||||||
@@ -84,4 +102,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
@@ -55,6 +55,74 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
LoRa ISM band — frequency depends on region
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Device Antenna</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Most devices:</strong> Ship with a small 915/868 MHz stubby antenna</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Works for:</strong> Short range (< 1 km) urban, indoor testing</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Upgrade:</strong> Replace with tuned antenna for 5–20x range improvement</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: #00ff88; font-size: 12px;">Recommended Upgrades</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Whip antenna:</strong> ~$8–15, tuned 915/868 MHz, SMA connector</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Ground plane:</strong> 8.2 cm vertical + 4 radials (915 MHz) on SMA</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Yagi:</strong> ~$15–30, directional, great for point-to-point links</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Collinear:</strong> ~$20–40, omnidirectional with higher gain (~5–8 dBi)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Height wins:</strong> Elevating antenna 10m can double or triple range</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Line of sight:</strong> LoRa works best with clear LOS to other nodes</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Connector:</strong> Most devices use SMA or RP-SMA — check before buying</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">US / Americas</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">915 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">EU / UK / India</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">868 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">915 MHz λ/4</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">868 MHz λ/4</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.6 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">LoRa (CSS)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Typical range</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1–15 km</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
Pager frequencies vary by region (130–930 MHz)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Works for:</strong> UHF pager bands (~900 MHz) — the stock antenna is tuned near 1 GHz</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Extend to:</strong> ~8 cm for 929 MHz (quarter-wave)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">For VHF (~150 MHz):</strong> Stock antenna is too short. Build a dipole (see below)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Best for VHF Pagers)</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">For 153 MHz:</strong> Two elements, each ~49 cm (quarter-wave)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">For 929 MHz:</strong> Two elements, each ~8 cm</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Formula:</strong> Element length (cm) = 7500 / frequency (MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Any wire, coat hanger, or copper rod</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (pager signals are vertically polarized)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Common UHF freq</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">929 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Common VHF freq</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">153.350 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM (NFM)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~12.5 kHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="run-btn" id="startBtn" onclick="startDecoding()">
|
<button class="run-btn" id="startBtn" onclick="startDecoding()">
|
||||||
Start Decoding
|
Start Decoding
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -58,6 +58,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
ISM 900 MHz band — stock antenna is close but not optimal
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">912 MHz:</strong> Extend to ~8.2 cm (quarter-wave). The stock antenna is close enough to work</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Range:</strong> Most meters transmit at ~100 mW, expect 50–200 m range with stock antenna</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Upgraded Options</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Ground Plane:</strong> 8.2 cm vertical + four 8.2 cm radials at 45° on SMA connector</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Yagi:</strong> Directional for targeting specific meters at distance (~$15–25)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Placement:</strong> Near a window facing the meters. Line-of-sight matters most</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency (NA)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">912 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency (EU)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">868 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">912 MHz λ/4</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Meter TX power</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~100 mW</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="run-btn" id="startRtlamrBtn" onclick="startRtlamrDecoding()">
|
<button class="run-btn" id="startRtlamrBtn" onclick="startRtlamrDecoding()">
|
||||||
Start Listening
|
Start Listening
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,6 +39,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
ISM band devices (433 / 868 / 915 MHz)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">433 MHz:</strong> Extend to ~17 cm (quarter-wave). Stock antenna works but isn't ideal</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">868/915 MHz:</strong> Extend to ~8 cm. Stock antenna is nearly tuned for this</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quarter-Wave Ground Plane (Best)</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">433 MHz:</strong> Vertical element 17.3 cm + four 17.3 cm radials at 45°</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">868 MHz:</strong> Vertical element 8.6 cm + four 8.6 cm radials</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">915 MHz:</strong> Vertical element 8.2 cm + four 8.2 cm radials</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Stiff copper wire soldered to an SMA connector</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors or near a window. Higher is better for range</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">433 MHz λ/4</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">17.3 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">868 MHz λ/4</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.6 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">915 MHz λ/4</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Typical range</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">50–300 m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="run-btn" id="startSensorBtn" onclick="startSensorDecoding()">
|
<button class="run-btn" id="startSensorBtn" onclick="startSensorDecoding()">
|
||||||
Start Listening
|
Start Listening
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,4 +39,64 @@
|
|||||||
Common modes: PD120, PD180, Martin1, Scottie1
|
Common modes: PD120, PD180, Martin1, Scottie1
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
2m band (145.800 MHz) — stock SDR antenna will NOT work
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest — ~$5)</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~51 cm each (quarter-wave at 145.8 MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Angle:</strong> 120° between elements for partial RHCP</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Lay flat, angled toward the ISS pass direction</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||||
|
Same antenna as weather satellites (similar frequency). A QFH or turnstile for 137 MHz also works well here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Tips for ISS Reception</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">ISS altitude:</strong> ~420 km, overhead passes last 5–10 minutes</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Best passes:</strong> Elevation > 30° for clear signal</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Outdoors:</strong> Clear sky view is essential. Roof or open field</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">LNA:</strong> Optional but helps — 2m filtered LNA at antenna feed</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Doppler:</strong> ISS moves fast — signal shifts ±3.5 kHz during pass</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">ISS SSTV frequency</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">145.800 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">51 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM (25 kHz)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP (circular)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Typical pass duration</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">5–10 min</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</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">
|
<div id="tscmMode" class="mode-content">
|
||||||
<!-- Configuration -->
|
<!-- Configuration -->
|
||||||
<div class="section">
|
<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">
|
<div class="form-group">
|
||||||
<label>Sweep Type</label>
|
<label>Sweep Type</label>
|
||||||
@@ -65,14 +65,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Futuristic Scanner Progress -->
|
||||||
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
|
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
|
||||||
<div class="scanner-ring">
|
<div class="scanner-ring">
|
||||||
@@ -115,8 +107,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced -->
|
<!-- Advanced -->
|
||||||
<div class="section" style="margin-top: 12px;">
|
<div class="section">
|
||||||
<h3 style="margin-bottom: 12px;">Advanced</h3>
|
<h3>Advanced</h3>
|
||||||
|
|
||||||
<div style="margin-bottom: 16px;">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Tools -->
|
<!-- Tools -->
|
||||||
<div class="section" style="margin-top: 12px;">
|
<div class="section">
|
||||||
<h3 style="margin-bottom: 10px;">Tools</h3>
|
<h3>Tools</h3>
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
|
||||||
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
|
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
|
||||||
Capabilities
|
Capabilities
|
||||||
@@ -182,4 +174,13 @@
|
|||||||
|
|
||||||
<!-- Device Warnings -->
|
<!-- 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 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>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<!-- WEATHER SATELLITE MODE -->
|
||||||
|
<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="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">
|
||||||
|
<label>Gain (dB)</label>
|
||||||
|
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<input type="checkbox" id="weatherSatBiasT" style="width: auto;">
|
||||||
|
Bias-T (power LNA)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide - detailed -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
|
||||||
|
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
137 MHz band — your stock SDR antenna will NOT work.
|
||||||
|
</p>
|
||||||
|
<p style="margin-bottom: 10px;">
|
||||||
|
Weather satellites transmit at 137.1–137.9 MHz. The quarter-wave
|
||||||
|
at this frequency is <strong style="color: var(--text-primary);">~53 cm</strong>,
|
||||||
|
far longer than the small telescopic antenna shipped with most SDRs
|
||||||
|
(tuned for ~1 GHz). You need a purpose-built antenna.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- V-Dipole -->
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest — ~$5)</strong>
|
||||||
|
|
||||||
|
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> coax to SDR
|
||||||
|
|
|
||||||
|
===+=== feed point
|
||||||
|
/ \
|
||||||
|
/ 120 \
|
||||||
|
/ \
|
||||||
|
/ deg \
|
||||||
|
53.4cm 53.4cm</div>
|
||||||
|
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Element length:</strong> 53.4 cm each (quarter wavelength at 137 MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Angle:</strong> 120° between elements (not 180°)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Any stiff wire, coat hanger, or copper rod</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Lay flat or tilt 30° toward expected pass direction</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Polarization:</strong> The 120° angle gives partial RHCP match to satellite signal</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||||
|
Best starter antenna. Good enough for clear NOAA images with a direct overhead pass.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Turnstile -->
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Turnstile / Crossed Dipole (~$10-15)</strong>
|
||||||
|
|
||||||
|
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> 53.4cm
|
||||||
|
<--------->
|
||||||
|
====+==== dipole 1
|
||||||
|
|
|
||||||
|
====+==== dipole 2
|
||||||
|
<--------->
|
||||||
|
90 deg rotated
|
||||||
|
+ reflector below</div>
|
||||||
|
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Elements:</strong> Two crossed dipoles, each 53.4 cm per side (4 elements total)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Angle:</strong> 90° between the two dipole pairs</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Phasing:</strong> Feed dipole 2 with a 90° delay (quarter-wave coax section ~37 cm of RG-58)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Reflector:</strong> Place ~52 cm below elements (ground plane or wire grid)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Polarization:</strong> Circular (RHCP) — matches satellite transmission</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||||
|
Better than V-dipole. The reflector rejects ground noise and the RHCP phasing matches the satellite signal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QFH -->
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: #00ff88; font-size: 12px;">QFH — Quadrifilar Helix (Best — ~$20-30)</strong>
|
||||||
|
|
||||||
|
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> ___
|
||||||
|
/ \ two helix loops
|
||||||
|
| | | twisted 90 deg
|
||||||
|
| | | around a mast
|
||||||
|
\___/
|
||||||
|
|
|
||||||
|
coax</div>
|
||||||
|
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Design:</strong> Two bifilar helical loops, offset 90°</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Copper pipe (10mm), copper wire, or coax outer shield</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Total height:</strong> ~46 cm (for 137 MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Loop dimensions:</strong> Use a QFH calculator for exact bending measurements</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Polarization:</strong> True RHCP omnidirectional — ideal for overhead satellite passes</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Gain pattern:</strong> Hemispherical upward coverage, rejects ground interference</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||||
|
Gold standard for weather satellite reception. No tracking needed — covers the whole sky.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Placement & LNA -->
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement & LNA</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS with clear sky view is critical. Roof/balcony/open field.</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Height:</strong> Higher is better but not critical — clear horizon line matters more</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (<10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
|
||||||
|
Recommended: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick reference -->
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Wavelength (137 MHz)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">218.8 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter wave (element length)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">53.4 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Best pass elevation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">> 30°</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Typical pass duration</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">10-15 min</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">NOAA (APT) bandwidth</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~40 kHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
||||||
|
Test Decode (File)
|
||||||
|
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||||
|
Decode a pre-recorded IQ or WAV file without SDR hardware.
|
||||||
|
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files.
|
||||||
|
</p>
|
||||||
|
<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">NOAA-18 (APT)</option>
|
||||||
|
<option value="NOAA-19">NOAA-19 (APT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>File Path (server-side)</label>
|
||||||
|
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Sample Rate</label>
|
||||||
|
<select id="wxsatTestSampleRate" class="mode-select">
|
||||||
|
<option value="11025">11025 Hz (WAV audio APT)</option>
|
||||||
|
<option value="48000">48000 Hz (WAV audio APT)</option>
|
||||||
|
<option value="500000">500 kHz (IQ LRPT)</option>
|
||||||
|
<option value="1000000" selected>1 MHz (IQ default)</option>
|
||||||
|
<option value="2000000">2 MHz (IQ wideband)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
||||||
|
Test Decode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Auto-Scheduler</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||||
|
Automatically capture satellite passes based on predictions.
|
||||||
|
Set your location above and toggle AUTO in the strip bar.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler()" style="width: auto;">
|
||||||
|
Enable Auto-Capture
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="wxsatSchedulerStatus" style="font-size: 11px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; margin-top: 4px;">
|
||||||
|
Disabled
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
SatDump Documentation
|
||||||
|
</a>
|
||||||
|
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
NOAA Reception Guide
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<!-- WiFi MODE -->
|
<!-- WiFi MODE -->
|
||||||
<div id="wifiMode" class="mode-content">
|
<div id="wifiMode" class="mode-content">
|
||||||
<!-- Scan Mode Tabs -->
|
<!-- 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;">
|
<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;">
|
<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
|
Quick Scan
|
||||||
@@ -168,29 +169,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Export Section -->
|
||||||
<div class="section" style="margin-top: 10px;">
|
<div class="section">
|
||||||
<h3>Export</h3>
|
<h3>Export</h3>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
|
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
|
||||||
@@ -201,4 +181,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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('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('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('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('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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
<div class="mode-nav-dropdown-menu">
|
<div class="mode-nav-dropdown-menu">
|
||||||
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
|
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
|
||||||
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,7 +119,9 @@
|
|||||||
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
|
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
||||||
|
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
|
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,6 +181,7 @@
|
|||||||
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||||
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
||||||
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
{% if is_index_page %}
|
{% if is_index_page %}
|
||||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||||
@@ -185,12 +189,14 @@
|
|||||||
{{ 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>', '/satellite/dashboard') }}
|
{{ 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>', '/satellite/dashboard') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
|
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
|
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
{{ 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('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>
|
</nav>
|
||||||
|
|
||||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
|
|||||||
@@ -323,7 +323,6 @@
|
|||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="rtlamr">RTLAMR</option>
|
<option value="rtlamr">RTLAMR</option>
|
||||||
<option value="dmr">DMR</option>
|
|
||||||
<option value="tscm">TSCM</option>
|
<option value="tscm">TSCM</option>
|
||||||
<option value="sstv">SSTV</option>
|
<option value="sstv">SSTV</option>
|
||||||
<option value="sstv_general">SSTV General</option>
|
<option value="sstv_general">SSTV General</option>
|
||||||
|
|||||||
@@ -107,12 +107,8 @@
|
|||||||
<label>TARGET:</label>
|
<label>TARGET:</label>
|
||||||
<select id="satSelect" onchange="onSatelliteChange()">
|
<select id="satSelect" onchange="onSatelliteChange()">
|
||||||
<option value="25544">ISS (ZARYA)</option>
|
<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="40069">METEOR-M2</option>
|
||||||
<option value="43013">NOAA 20</option>
|
<option value="57166">METEOR-M2-3</option>
|
||||||
<option value="54234">METEOR-M2-3</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -273,16 +269,42 @@
|
|||||||
let currentLocationSource = 'local';
|
let currentLocationSource = 'local';
|
||||||
let agents = [];
|
let agents = [];
|
||||||
|
|
||||||
const satellites = {
|
let satellites = {
|
||||||
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
|
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
|
||||||
25338: { name: 'NOAA 15', color: '#00ff00' },
|
|
||||||
28654: { name: 'NOAA 18', color: '#ff6600' },
|
|
||||||
33591: { name: 'NOAA 19', color: '#ff3366' },
|
|
||||||
40069: { name: 'METEOR-M2', color: '#9370DB' },
|
40069: { name: 'METEOR-M2', color: '#9370DB' },
|
||||||
43013: { name: 'NOAA 20', color: '#00ffaa' },
|
57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
|
||||||
54234: { name: 'METEOR-M2-3', color: '#ff00ff' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const satColors = ['#00ffff', '#9370DB', '#ff00ff', '#00ff00', '#ff6600', '#ffff00', '#ff69b4', '#7b68ee'];
|
||||||
|
|
||||||
|
function loadDashboardSatellites() {
|
||||||
|
fetch('/satellite/tracked?enabled=true')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success' && data.satellites && data.satellites.length > 0) {
|
||||||
|
const newSats = {};
|
||||||
|
const select = document.getElementById('satSelect');
|
||||||
|
select.innerHTML = '';
|
||||||
|
data.satellites.forEach((sat, i) => {
|
||||||
|
const norad = parseInt(sat.norad_id);
|
||||||
|
newSats[norad] = {
|
||||||
|
name: sat.name,
|
||||||
|
color: satellites[norad]?.color || satColors[i % satColors.length]
|
||||||
|
};
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = norad;
|
||||||
|
opt.textContent = sat.name;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
satellites = newSats;
|
||||||
|
// Default to ISS if available
|
||||||
|
if (newSats[25544]) select.value = '25544';
|
||||||
|
selectedSatellite = parseInt(select.value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function onSatelliteChange() {
|
function onSatelliteChange() {
|
||||||
const select = document.getElementById('satSelect');
|
const select = document.getElementById('satSelect');
|
||||||
selectedSatellite = parseInt(select.value);
|
selectedSatellite = parseInt(select.value);
|
||||||
@@ -339,6 +361,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadDashboardSatellites();
|
||||||
setupEmbeddedMode();
|
setupEmbeddedMode();
|
||||||
const usedShared = applySharedObserverLocation();
|
const usedShared = applySharedObserverLocation();
|
||||||
initGroundMap();
|
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()
|
||||||
+107
-6
@@ -1,7 +1,9 @@
|
|||||||
"""Tests for the DMR / Digital Voice decoding module."""
|
"""Tests for the DMR / Digital Voice decoding module."""
|
||||||
|
|
||||||
|
import queue
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import pytest
|
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
|
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +68,16 @@ def test_parse_talkgroup_dsd_fme_format():
|
|||||||
assert result['source_id'] == 67890
|
assert result['source_id'] == 67890
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_talkgroup_dsd_fme_tgt_src_format():
|
||||||
|
"""Should parse dsd-fme TGT/SRC pipe-delimited format."""
|
||||||
|
result = parse_dsd_output('Slot 1 | TGT: 12345 | SRC: 67890')
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'call'
|
||||||
|
assert result['talkgroup'] == 12345
|
||||||
|
assert result['source_id'] == 67890
|
||||||
|
assert result['slot'] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_parse_talkgroup_with_slot():
|
def test_parse_talkgroup_with_slot():
|
||||||
"""TG line with slot info should capture both."""
|
"""TG line with slot info should capture both."""
|
||||||
result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200')
|
result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200')
|
||||||
@@ -98,6 +110,23 @@ def test_parse_unrecognized():
|
|||||||
assert result['text'] == 'some random text'
|
assert result['text'] == 'some random text'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_banner_filtered():
|
||||||
|
"""Pure box-drawing lines (banners) should be filtered."""
|
||||||
|
assert parse_dsd_output('╔══════════════╗') is None
|
||||||
|
assert parse_dsd_output('║ ║') is None
|
||||||
|
assert parse_dsd_output('╚══════════════╝') is None
|
||||||
|
assert parse_dsd_output('───────────────') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_box_drawing_with_data_not_filtered():
|
||||||
|
"""Lines with box-drawing separators AND data should NOT be filtered."""
|
||||||
|
result = parse_dsd_output('DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890')
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'call'
|
||||||
|
assert result['talkgroup'] == 12345
|
||||||
|
assert result['source_id'] == 67890
|
||||||
|
|
||||||
|
|
||||||
def test_dsd_fme_flags_differ_from_classic():
|
def test_dsd_fme_flags_differ_from_classic():
|
||||||
"""dsd-fme remapped several flags; tables must NOT be identical."""
|
"""dsd-fme remapped several flags; tables must NOT be identical."""
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS
|
assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS
|
||||||
@@ -105,11 +134,11 @@ def test_dsd_fme_flags_differ_from_classic():
|
|||||||
|
|
||||||
def test_dsd_fme_protocol_flags_known_values():
|
def test_dsd_fme_protocol_flags_known_values():
|
||||||
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
|
"""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'] == ['-fd']
|
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['nxdn'] == ['-fn']
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == [] # No dedicated flag
|
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
|
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
|
||||||
|
|
||||||
|
|
||||||
@@ -126,9 +155,9 @@ def test_dsd_protocol_flags_known_values():
|
|||||||
def test_dsd_fme_modulation_hints():
|
def test_dsd_fme_modulation_hints():
|
||||||
"""C4FM modulation hints should be set for C4FM protocols."""
|
"""C4FM modulation hints should be set for C4FM protocols."""
|
||||||
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
|
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
|
||||||
assert _DSD_FME_MODULATION['p25'] == ['-mc']
|
|
||||||
assert _DSD_FME_MODULATION['nxdn'] == ['-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 'dstar' not in _DSD_FME_MODULATION
|
||||||
assert 'provoice' not in _DSD_FME_MODULATION
|
assert 'provoice' not in _DSD_FME_MODULATION
|
||||||
|
|
||||||
@@ -145,6 +174,40 @@ def auth_client(client):
|
|||||||
return 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):
|
def test_dmr_tools(auth_client):
|
||||||
"""Tools endpoint should return availability info."""
|
"""Tools endpoint should return availability info."""
|
||||||
resp = auth_client.get('/dmr/tools')
|
resp = auth_client.get('/dmr/tools')
|
||||||
@@ -208,3 +271,41 @@ def test_dmr_stream_mimetype(auth_client):
|
|||||||
"""Stream should return event-stream content type."""
|
"""Stream should return event-stream content type."""
|
||||||
resp = auth_client.get('/dmr/stream')
|
resp = auth_client.get('/dmr/stream')
|
||||||
assert resp.content_type.startswith('text/event-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):
|
def test_decode_mmsi_with_leading_zeros(self, decoder):
|
||||||
"""Test MMSI decoding handles leading zeros."""
|
"""Test MMSI decoding handles leading zeros."""
|
||||||
# Coast station: 002320001
|
# Coast station: 002320001
|
||||||
# 00-23-20-00-01 -> [0, 23, 20, 0, 1]
|
# Padded to 10 digits: 0002320001
|
||||||
symbols = [0, 23, 20, 0, 1]
|
# BCD pairs: 00-02-32-00-01 -> [0, 2, 32, 0, 1]
|
||||||
|
symbols = [0, 2, 32, 0, 1]
|
||||||
result = decoder._decode_mmsi(symbols)
|
result = decoder._decode_mmsi(symbols)
|
||||||
assert result == '002320001'
|
assert result == '002320001'
|
||||||
|
|
||||||
@@ -328,14 +329,16 @@ class TestDSCDecoder:
|
|||||||
# Symbols > 99 should be treated as 0
|
# Symbols > 99 should be treated as 0
|
||||||
symbols = [100, 32, 12, 34, 56]
|
symbols = [100, 32, 12, 34, 56]
|
||||||
result = decoder._decode_mmsi(symbols)
|
result = decoder._decode_mmsi(symbols)
|
||||||
# First symbol becomes 00
|
# First symbol (100) becomes 00, padded result "0032123456",
|
||||||
assert result == '003212345'[-9:]
|
# trim leading pad digit -> "032123456"
|
||||||
|
assert result == '032123456'
|
||||||
|
|
||||||
def test_decode_position_northeast(self, decoder):
|
def test_decode_position_northeast(self, decoder):
|
||||||
"""Test position decoding for NE quadrant."""
|
"""Test position decoding for NE quadrant."""
|
||||||
# Quadrant 10 = NE (lat+, lon+)
|
# Quadrant 10 = NE (lat+, lon+)
|
||||||
# Position: 51°30'N, 0°10'E
|
# Position: 51°30'N, 0°10'E
|
||||||
symbols = [10, 51, 30, 0, 10, 0, 0, 0, 0, 0]
|
# lon_deg = symbols[3]*100 + symbols[4] = 0, lon_min = symbols[5] = 10
|
||||||
|
symbols = [10, 51, 30, 0, 0, 10, 0, 0, 0, 0]
|
||||||
result = decoder._decode_position(symbols)
|
result = decoder._decode_position(symbols)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|||||||
+20
-10
@@ -46,20 +46,30 @@ class TestHealthEndpoint:
|
|||||||
assert 'processes' in data
|
assert 'processes' in data
|
||||||
assert 'data' in data
|
assert 'data' in data
|
||||||
|
|
||||||
def test_health_process_status(self, client):
|
def test_health_process_status(self, client):
|
||||||
"""Test health endpoint reports process status."""
|
"""Test health endpoint reports process status."""
|
||||||
response = client.get('/health')
|
response = client.get('/health')
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
|
|
||||||
processes = data['processes']
|
processes = data['processes']
|
||||||
assert 'pager' in processes
|
assert 'pager' in processes
|
||||||
assert 'sensor' in processes
|
assert 'sensor' in processes
|
||||||
assert 'adsb' in processes
|
assert 'adsb' in processes
|
||||||
assert 'wifi' in processes
|
assert 'wifi' in processes
|
||||||
assert 'bluetooth' in processes
|
assert 'bluetooth' in processes
|
||||||
|
|
||||||
|
def test_health_reports_dmr_route_process(self, client):
|
||||||
class TestDevicesEndpoint:
|
"""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."""
|
"""Tests for devices endpoint."""
|
||||||
|
|
||||||
def test_get_devices(self, client):
|
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,643 @@
|
|||||||
|
"""Tests for WeatherSatDecoder class.
|
||||||
|
|
||||||
|
Covers WeatherSatDecoder methods, subprocess management, progress callbacks,
|
||||||
|
and image handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock, call, mock_open
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.weather_sat import (
|
||||||
|
WeatherSatDecoder,
|
||||||
|
WeatherSatImage,
|
||||||
|
CaptureProgress,
|
||||||
|
WEATHER_SATELLITES,
|
||||||
|
get_weather_sat_decoder,
|
||||||
|
is_weather_sat_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatDecoder:
|
||||||
|
"""Tests for WeatherSatDecoder class."""
|
||||||
|
|
||||||
|
def test_decoder_initialization(self):
|
||||||
|
"""Decoder should initialize with default output directory."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
assert decoder.is_running is False
|
||||||
|
assert decoder.decoder_available == 'satdump'
|
||||||
|
assert decoder.current_satellite == ''
|
||||||
|
assert decoder.current_frequency == 0.0
|
||||||
|
|
||||||
|
def test_decoder_initialization_no_satdump(self):
|
||||||
|
"""Decoder should detect when SatDump is unavailable."""
|
||||||
|
with patch('shutil.which', return_value=None):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
assert decoder.decoder_available is None
|
||||||
|
|
||||||
|
def test_decoder_custom_output_dir(self):
|
||||||
|
"""Decoder should accept custom output directory."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
custom_dir = '/tmp/custom_output'
|
||||||
|
decoder = WeatherSatDecoder(output_dir=custom_dir)
|
||||||
|
assert decoder._output_dir == Path(custom_dir)
|
||||||
|
|
||||||
|
def test_set_callback(self):
|
||||||
|
"""Decoder should accept progress callback."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
assert decoder._callback == callback
|
||||||
|
|
||||||
|
def test_set_on_complete(self):
|
||||||
|
"""Decoder should accept on_complete callback."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_on_complete(callback)
|
||||||
|
assert decoder._on_complete_callback == callback
|
||||||
|
|
||||||
|
def test_start_no_decoder(self):
|
||||||
|
"""start() should fail when no decoder available."""
|
||||||
|
with patch('shutil.which', return_value=None):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
callback.assert_called()
|
||||||
|
progress = callback.call_args[0][0]
|
||||||
|
assert progress.status == 'error'
|
||||||
|
assert 'SatDump' in progress.message
|
||||||
|
|
||||||
|
def test_start_invalid_satellite(self):
|
||||||
|
"""start() should fail with invalid satellite."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
callback.assert_called()
|
||||||
|
progress = callback.call_args[0][0]
|
||||||
|
assert progress.status == 'error'
|
||||||
|
assert 'Unknown satellite' in progress.message
|
||||||
|
|
||||||
|
@patch('subprocess.Popen')
|
||||||
|
@patch('pty.openpty')
|
||||||
|
@patch('utils.weather_sat.register_process')
|
||||||
|
def test_start_success(self, mock_register, mock_pty, mock_popen):
|
||||||
|
"""start() should successfully start SatDump."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||||
|
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
|
||||||
|
|
||||||
|
mock_pty.return_value = (10, 11)
|
||||||
|
mock_process = MagicMock()
|
||||||
|
mock_process.poll.return_value = None
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start(
|
||||||
|
satellite='NOAA-18',
|
||||||
|
device_index=0,
|
||||||
|
gain=40.0,
|
||||||
|
bias_t=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert decoder.is_running is True
|
||||||
|
assert decoder.current_satellite == 'NOAA-18'
|
||||||
|
assert decoder.current_frequency == 137.9125
|
||||||
|
assert decoder.current_mode == 'APT'
|
||||||
|
assert decoder.device_index == 0
|
||||||
|
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
cmd = mock_popen.call_args[0][0]
|
||||||
|
assert cmd[0] == 'satdump'
|
||||||
|
assert 'live' in cmd
|
||||||
|
assert 'noaa_apt' in cmd
|
||||||
|
assert '--bias' in cmd
|
||||||
|
|
||||||
|
@patch('subprocess.Popen')
|
||||||
|
@patch('pty.openpty')
|
||||||
|
def test_start_already_running(self, mock_pty, mock_popen):
|
||||||
|
"""start() should return True when already running."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
decoder._running = True
|
||||||
|
|
||||||
|
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
mock_popen.assert_not_called()
|
||||||
|
|
||||||
|
@patch('subprocess.Popen')
|
||||||
|
@patch('pty.openpty')
|
||||||
|
def test_start_exception_handling(self, mock_pty, mock_popen):
|
||||||
|
"""start() should handle exceptions gracefully."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
mock_pty.return_value = (10, 11)
|
||||||
|
mock_popen.side_effect = OSError('Device not found')
|
||||||
|
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert decoder.is_running is False
|
||||||
|
callback.assert_called()
|
||||||
|
progress = callback.call_args[0][0]
|
||||||
|
assert progress.status == 'error'
|
||||||
|
|
||||||
|
def test_start_from_file_no_decoder(self):
|
||||||
|
"""start_from_file() should fail when no decoder available."""
|
||||||
|
with patch('shutil.which', return_value=None):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start_from_file(
|
||||||
|
satellite='NOAA-18',
|
||||||
|
input_file='data/test.wav',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
callback.assert_called()
|
||||||
|
|
||||||
|
@patch('subprocess.Popen')
|
||||||
|
@patch('pty.openpty')
|
||||||
|
@patch('pathlib.Path.is_file', return_value=True)
|
||||||
|
@patch('pathlib.Path.resolve')
|
||||||
|
def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
|
||||||
|
"""start_from_file() should successfully decode from file."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
||||||
|
patch('utils.weather_sat.register_process'):
|
||||||
|
|
||||||
|
# Mock path resolution
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.is_relative_to.return_value = True
|
||||||
|
mock_path.suffix = '.wav'
|
||||||
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
|
mock_pty.return_value = (10, 11)
|
||||||
|
mock_process = MagicMock()
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start_from_file(
|
||||||
|
satellite='NOAA-18',
|
||||||
|
input_file='data/test.wav',
|
||||||
|
sample_rate=1000000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert decoder.is_running is True
|
||||||
|
assert decoder.current_satellite == 'NOAA-18'
|
||||||
|
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
cmd = mock_popen.call_args[0][0]
|
||||||
|
assert cmd[0] == 'satdump'
|
||||||
|
assert 'noaa_apt' in cmd
|
||||||
|
assert 'audio_wav' in cmd
|
||||||
|
assert '--samplerate' in cmd
|
||||||
|
|
||||||
|
@patch('pathlib.Path.resolve')
|
||||||
|
def test_start_from_file_path_traversal(self, mock_resolve):
|
||||||
|
"""start_from_file() should block path traversal."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
# Mock path outside allowed directory
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.is_relative_to.return_value = False
|
||||||
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start_from_file(
|
||||||
|
satellite='NOAA-18',
|
||||||
|
input_file='/etc/passwd',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
callback.assert_called()
|
||||||
|
progress = callback.call_args[0][0]
|
||||||
|
assert 'data/ directory' in progress.message
|
||||||
|
|
||||||
|
@patch('pathlib.Path.is_file', return_value=False)
|
||||||
|
@patch('pathlib.Path.resolve')
|
||||||
|
def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
|
||||||
|
"""start_from_file() should fail when file not found."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.is_relative_to.return_value = True
|
||||||
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
success = decoder.start_from_file(
|
||||||
|
satellite='NOAA-18',
|
||||||
|
input_file='data/missing.wav',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
callback.assert_called()
|
||||||
|
progress = callback.call_args[0][0]
|
||||||
|
assert 'not found' in progress.message.lower()
|
||||||
|
|
||||||
|
def test_stop_not_running(self):
|
||||||
|
"""stop() should be safe when not running."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
decoder.stop() # Should not raise
|
||||||
|
|
||||||
|
@patch('utils.weather_sat.safe_terminate')
|
||||||
|
def test_stop_running(self, mock_terminate):
|
||||||
|
"""stop() should terminate process."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
mock_process = MagicMock()
|
||||||
|
decoder._process = mock_process
|
||||||
|
decoder._running = True
|
||||||
|
decoder._pty_master_fd = 10
|
||||||
|
|
||||||
|
with patch('os.close') as mock_close:
|
||||||
|
decoder.stop()
|
||||||
|
|
||||||
|
assert decoder._running is False
|
||||||
|
mock_terminate.assert_called_once_with(mock_process)
|
||||||
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
|
def test_get_images_empty(self):
|
||||||
|
"""get_images() should return empty list initially."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
images = decoder.get_images()
|
||||||
|
assert images == []
|
||||||
|
|
||||||
|
@patch('pathlib.Path.glob')
|
||||||
|
@patch('pathlib.Path.stat')
|
||||||
|
def test_get_images_scans_directory(self, mock_stat, mock_glob):
|
||||||
|
"""get_images() should scan output directory."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
|
# Mock image files
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.name = 'NOAA-18_test.png'
|
||||||
|
mock_file.stat.return_value.st_size = 10000
|
||||||
|
mock_file.stat.return_value.st_mtime = time.time()
|
||||||
|
mock_glob.return_value = [mock_file]
|
||||||
|
|
||||||
|
images = decoder.get_images()
|
||||||
|
|
||||||
|
assert len(images) == 1
|
||||||
|
assert images[0].filename == 'NOAA-18_test.png'
|
||||||
|
assert images[0].satellite == 'NOAA-18'
|
||||||
|
|
||||||
|
def test_delete_image_success(self):
|
||||||
|
"""delete_image() should delete file."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
|
with patch('pathlib.Path.exists', return_value=True), \
|
||||||
|
patch('pathlib.Path.unlink') as mock_unlink:
|
||||||
|
|
||||||
|
result = decoder.delete_image('test.png')
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_unlink.assert_called_once()
|
||||||
|
|
||||||
|
def test_delete_image_not_found(self):
|
||||||
|
"""delete_image() should return False for non-existent file."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
|
with patch('pathlib.Path.exists', return_value=False):
|
||||||
|
result = decoder.delete_image('missing.png')
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_delete_all_images(self):
|
||||||
|
"""delete_all_images() should delete all images."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
|
mock_files = [MagicMock() for _ in range(3)]
|
||||||
|
with patch('pathlib.Path.glob', return_value=mock_files):
|
||||||
|
count = decoder.delete_all_images()
|
||||||
|
|
||||||
|
assert count == 3
|
||||||
|
for f in mock_files:
|
||||||
|
f.unlink.assert_called_once()
|
||||||
|
|
||||||
|
def test_get_status_idle(self):
|
||||||
|
"""get_status() should return idle status."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
status = decoder.get_status()
|
||||||
|
|
||||||
|
assert status['available'] is True
|
||||||
|
assert status['decoder'] == 'satdump'
|
||||||
|
assert status['running'] is False
|
||||||
|
assert status['satellite'] == ''
|
||||||
|
|
||||||
|
def test_get_status_running(self):
|
||||||
|
"""get_status() should return running status."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
decoder._running = True
|
||||||
|
decoder._current_satellite = 'NOAA-18'
|
||||||
|
decoder._current_frequency = 137.9125
|
||||||
|
decoder._current_mode = 'APT'
|
||||||
|
decoder._capture_start_time = time.time() - 60
|
||||||
|
|
||||||
|
status = decoder.get_status()
|
||||||
|
|
||||||
|
assert status['running'] is True
|
||||||
|
assert status['satellite'] == 'NOAA-18'
|
||||||
|
assert status['frequency'] == 137.9125
|
||||||
|
assert status['mode'] == 'APT'
|
||||||
|
assert status['elapsed_seconds'] >= 60
|
||||||
|
|
||||||
|
def test_classify_log_type_error(self):
|
||||||
|
"""_classify_log_type() should detect errors."""
|
||||||
|
assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error'
|
||||||
|
assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error'
|
||||||
|
|
||||||
|
def test_classify_log_type_progress(self):
|
||||||
|
"""_classify_log_type() should detect progress."""
|
||||||
|
assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress'
|
||||||
|
|
||||||
|
def test_classify_log_type_save(self):
|
||||||
|
"""_classify_log_type() should detect save events."""
|
||||||
|
assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save'
|
||||||
|
assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save'
|
||||||
|
|
||||||
|
def test_classify_log_type_signal(self):
|
||||||
|
"""_classify_log_type() should detect signal events."""
|
||||||
|
assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal'
|
||||||
|
assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal'
|
||||||
|
|
||||||
|
def test_classify_log_type_warning(self):
|
||||||
|
"""_classify_log_type() should detect warnings."""
|
||||||
|
assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning'
|
||||||
|
|
||||||
|
def test_classify_log_type_debug(self):
|
||||||
|
"""_classify_log_type() should detect debug messages."""
|
||||||
|
assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug'
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_resolve_device_id_success(self, mock_run):
|
||||||
|
"""_resolve_device_id() should extract serial from rtl_test."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000'
|
||||||
|
mock_result.stderr = ''
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
serial = WeatherSatDecoder._resolve_device_id(0)
|
||||||
|
|
||||||
|
assert serial == '00004000'
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_resolve_device_id_fallback(self, mock_run):
|
||||||
|
"""_resolve_device_id() should fall back to index string."""
|
||||||
|
mock_run.side_effect = FileNotFoundError
|
||||||
|
|
||||||
|
serial = WeatherSatDecoder._resolve_device_id(0)
|
||||||
|
|
||||||
|
assert serial == '0'
|
||||||
|
|
||||||
|
def test_parse_product_name_rgb(self):
|
||||||
|
"""_parse_product_name() should identify RGB composite."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png'))
|
||||||
|
assert product == 'RGB Composite'
|
||||||
|
|
||||||
|
def test_parse_product_name_thermal(self):
|
||||||
|
"""_parse_product_name() should identify thermal imagery."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png'))
|
||||||
|
assert product == 'Thermal'
|
||||||
|
|
||||||
|
def test_parse_product_name_channel(self):
|
||||||
|
"""_parse_product_name() should identify channel images."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
product = decoder._parse_product_name(Path('/tmp/output/channel_3.png'))
|
||||||
|
assert product == 'Channel 3'
|
||||||
|
|
||||||
|
def test_parse_product_name_unknown(self):
|
||||||
|
"""_parse_product_name() should return stem for unknown products."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png'))
|
||||||
|
assert product == 'unknown_image'
|
||||||
|
|
||||||
|
def test_emit_progress(self):
|
||||||
|
"""_emit_progress() should call callback."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock()
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
progress = CaptureProgress(status='capturing', message='Test')
|
||||||
|
decoder._emit_progress(progress)
|
||||||
|
|
||||||
|
callback.assert_called_once_with(progress)
|
||||||
|
|
||||||
|
def test_emit_progress_no_callback(self):
|
||||||
|
"""_emit_progress() should handle missing callback."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
progress = CaptureProgress(status='capturing', message='Test')
|
||||||
|
decoder._emit_progress(progress) # Should not raise
|
||||||
|
|
||||||
|
def test_emit_progress_callback_exception(self):
|
||||||
|
"""_emit_progress() should handle callback exceptions."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
decoder = WeatherSatDecoder()
|
||||||
|
callback = MagicMock(side_effect=Exception('Callback error'))
|
||||||
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
|
progress = CaptureProgress(status='capturing', message='Test')
|
||||||
|
decoder._emit_progress(progress) # Should not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatImage:
|
||||||
|
"""Tests for WeatherSatImage dataclass."""
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""WeatherSatImage.to_dict() should serialize correctly."""
|
||||||
|
image = WeatherSatImage(
|
||||||
|
filename='test.png',
|
||||||
|
path=Path('/tmp/test.png'),
|
||||||
|
satellite='NOAA-18',
|
||||||
|
mode='APT',
|
||||||
|
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
frequency=137.9125,
|
||||||
|
size_bytes=12345,
|
||||||
|
product='RGB Composite',
|
||||||
|
)
|
||||||
|
|
||||||
|
data = image.to_dict()
|
||||||
|
|
||||||
|
assert data['filename'] == 'test.png'
|
||||||
|
assert data['satellite'] == 'NOAA-18'
|
||||||
|
assert data['mode'] == 'APT'
|
||||||
|
assert data['timestamp'] == '2024-01-01T12:00:00+00:00'
|
||||||
|
assert data['frequency'] == 137.9125
|
||||||
|
assert data['size_bytes'] == 12345
|
||||||
|
assert data['product'] == 'RGB Composite'
|
||||||
|
assert data['url'] == '/weather-sat/images/test.png'
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureProgress:
|
||||||
|
"""Tests for CaptureProgress dataclass."""
|
||||||
|
|
||||||
|
def test_to_dict_minimal(self):
|
||||||
|
"""CaptureProgress.to_dict() with minimal fields."""
|
||||||
|
progress = CaptureProgress(status='idle')
|
||||||
|
data = progress.to_dict()
|
||||||
|
|
||||||
|
assert data['type'] == 'weather_sat_progress'
|
||||||
|
assert data['status'] == 'idle'
|
||||||
|
assert data['satellite'] == ''
|
||||||
|
assert data['message'] == ''
|
||||||
|
assert data['progress'] == 0
|
||||||
|
|
||||||
|
def test_to_dict_complete(self):
|
||||||
|
"""CaptureProgress.to_dict() with all fields."""
|
||||||
|
image = WeatherSatImage(
|
||||||
|
filename='test.png',
|
||||||
|
path=Path('/tmp/test.png'),
|
||||||
|
satellite='NOAA-18',
|
||||||
|
mode='APT',
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=137.9125,
|
||||||
|
)
|
||||||
|
|
||||||
|
progress = CaptureProgress(
|
||||||
|
status='complete',
|
||||||
|
satellite='NOAA-18',
|
||||||
|
frequency=137.9125,
|
||||||
|
mode='APT',
|
||||||
|
message='Capture complete',
|
||||||
|
progress_percent=100,
|
||||||
|
elapsed_seconds=600,
|
||||||
|
image=image,
|
||||||
|
log_type='info',
|
||||||
|
capture_phase='complete',
|
||||||
|
)
|
||||||
|
|
||||||
|
data = progress.to_dict()
|
||||||
|
|
||||||
|
assert data['status'] == 'complete'
|
||||||
|
assert data['satellite'] == 'NOAA-18'
|
||||||
|
assert data['frequency'] == 137.9125
|
||||||
|
assert data['mode'] == 'APT'
|
||||||
|
assert data['message'] == 'Capture complete'
|
||||||
|
assert data['progress'] == 100
|
||||||
|
assert data['elapsed_seconds'] == 600
|
||||||
|
assert 'image' in data
|
||||||
|
assert data['log_type'] == 'info'
|
||||||
|
assert data['capture_phase'] == 'complete'
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalFunctions:
|
||||||
|
"""Tests for global utility functions."""
|
||||||
|
|
||||||
|
def test_get_weather_sat_decoder_singleton(self):
|
||||||
|
"""get_weather_sat_decoder() should return singleton."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
import utils.weather_sat as mod
|
||||||
|
old = mod._decoder
|
||||||
|
mod._decoder = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoder1 = get_weather_sat_decoder()
|
||||||
|
decoder2 = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
assert decoder1 is decoder2
|
||||||
|
finally:
|
||||||
|
mod._decoder = old
|
||||||
|
|
||||||
|
def test_is_weather_sat_available_true(self):
|
||||||
|
"""is_weather_sat_available() should return True when available."""
|
||||||
|
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
||||||
|
import utils.weather_sat as mod
|
||||||
|
old = mod._decoder
|
||||||
|
mod._decoder = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert is_weather_sat_available() is True
|
||||||
|
finally:
|
||||||
|
mod._decoder = old
|
||||||
|
|
||||||
|
def test_is_weather_sat_available_false(self):
|
||||||
|
"""is_weather_sat_available() should return False when unavailable."""
|
||||||
|
with patch('shutil.which', return_value=None):
|
||||||
|
import utils.weather_sat as mod
|
||||||
|
old = mod._decoder
|
||||||
|
mod._decoder = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert is_weather_sat_available() is False
|
||||||
|
finally:
|
||||||
|
mod._decoder = old
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatellitesConstant:
|
||||||
|
"""Tests for WEATHER_SATELLITES constant."""
|
||||||
|
|
||||||
|
def test_weather_satellites_structure(self):
|
||||||
|
"""WEATHER_SATELLITES should have correct structure."""
|
||||||
|
assert 'NOAA-18' in WEATHER_SATELLITES
|
||||||
|
sat = WEATHER_SATELLITES['NOAA-18']
|
||||||
|
|
||||||
|
assert 'name' in sat
|
||||||
|
assert 'frequency' in sat
|
||||||
|
assert 'mode' in sat
|
||||||
|
assert 'pipeline' in sat
|
||||||
|
assert 'tle_key' in sat
|
||||||
|
assert 'description' in sat
|
||||||
|
assert 'active' in sat
|
||||||
|
|
||||||
|
def test_noaa_satellites(self):
|
||||||
|
"""NOAA satellites should have correct frequencies."""
|
||||||
|
assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620
|
||||||
|
assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125
|
||||||
|
assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100
|
||||||
|
|
||||||
|
def test_meteor_satellite(self):
|
||||||
|
"""Meteor satellite should use LRPT mode."""
|
||||||
|
meteor = WEATHER_SATELLITES['METEOR-M2-3']
|
||||||
|
assert meteor['mode'] == 'LRPT'
|
||||||
|
assert meteor['frequency'] == 137.900
|
||||||
|
assert meteor['pipeline'] == 'meteor_m2-x_lrpt'
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
"""Tests for weather satellite pass prediction.
|
||||||
|
|
||||||
|
Covers predict_passes() function, TLE handling, trajectory computation,
|
||||||
|
and ground track generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.weather_sat_predict import predict_passes
|
||||||
|
|
||||||
|
|
||||||
|
class TestPredictPasses:
|
||||||
|
"""Tests for predict_passes() function."""
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
def test_predict_passes_no_tle_data(self, mock_tle, mock_load):
|
||||||
|
"""predict_passes() should handle missing TLE data."""
|
||||||
|
mock_tle.get.return_value = None
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
mock_ts.now.return_value = MagicMock()
|
||||||
|
mock_ts.utc.return_value = MagicMock()
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
assert passes == []
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_basic(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
||||||
|
"""predict_passes() should predict basic passes."""
|
||||||
|
# Mock timescale
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
# Mock TLE data
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock observer
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
# Mock satellite
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
# Mock pass detection - one pass
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
# Mock topocentric calculations
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 45.0
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
pass_data = passes[0]
|
||||||
|
assert pass_data['satellite'] == 'NOAA-18'
|
||||||
|
assert pass_data['name'] == 'NOAA 18'
|
||||||
|
assert pass_data['frequency'] == 137.9125
|
||||||
|
assert pass_data['mode'] == 'APT'
|
||||||
|
assert 'maxEl' in pass_data
|
||||||
|
assert 'duration' in pass_data
|
||||||
|
assert 'quality' in pass_data
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_below_min_elevation(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should filter passes below min elevation."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
# Mock low elevation pass
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 10.0 # Below min_elevation of 15
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
assert len(passes) == 0
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_with_trajectory(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should include trajectory when requested."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 45.0
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(
|
||||||
|
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
assert 'trajectory' in passes[0]
|
||||||
|
assert len(passes[0]['trajectory']) == 30
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_with_ground_track(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should include ground track when requested."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 45.0
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
# Mock geocentric position
|
||||||
|
def mock_at(t):
|
||||||
|
geocentric = MagicMock()
|
||||||
|
return geocentric
|
||||||
|
|
||||||
|
mock_satellite_obj.at.side_effect = mock_at
|
||||||
|
|
||||||
|
# Mock subpoint
|
||||||
|
mock_subpoint = MagicMock()
|
||||||
|
mock_lat = MagicMock()
|
||||||
|
mock_lat.degrees = 51.5
|
||||||
|
mock_lon = MagicMock()
|
||||||
|
mock_lon.degrees = -0.1
|
||||||
|
mock_subpoint.latitude = mock_lat
|
||||||
|
mock_subpoint.longitude = mock_lon
|
||||||
|
mock_wgs84.subpoint.return_value = mock_subpoint
|
||||||
|
|
||||||
|
passes = predict_passes(
|
||||||
|
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
assert 'groundTrack' in passes[0]
|
||||||
|
assert len(passes[0]['groundTrack']) == 60
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_quality_excellent(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should mark high elevation passes as excellent."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 75.0 # Excellent pass
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
assert passes[0]['quality'] == 'excellent'
|
||||||
|
assert passes[0]['maxEl'] >= 60
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_quality_good(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should mark medium elevation passes as good."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 45.0 # Good pass
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
assert passes[0]['quality'] == 'good'
|
||||||
|
assert 30 <= passes[0]['maxEl'] < 60
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_quality_fair(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should mark low elevation passes as fair."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 20.0 # Fair pass
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
assert passes[0]['quality'] == 'fair'
|
||||||
|
assert passes[0]['maxEl'] < 30
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_inactive_satellite(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should skip inactive satellites."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
# Temporarily mark satellite as inactive
|
||||||
|
from utils.weather_sat import WEATHER_SATELLITES
|
||||||
|
original_active = WEATHER_SATELLITES['NOAA-18']['active']
|
||||||
|
WEATHER_SATELLITES['NOAA-18']['active'] = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
# Should not include NOAA-18
|
||||||
|
noaa_18_passes = [p for p in passes if p['satellite'] == 'NOAA-18']
|
||||||
|
assert len(noaa_18_passes) == 0
|
||||||
|
finally:
|
||||||
|
WEATHER_SATELLITES['NOAA-18']['active'] = original_active
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_exception_handling(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should handle exceptions gracefully."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
# Make find_discrete raise exception
|
||||||
|
mock_find.side_effect = Exception('Computation error')
|
||||||
|
|
||||||
|
# Should not raise, just skip this satellite
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
# May include passes from other satellites or be empty
|
||||||
|
assert isinstance(passes, list)
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
def test_predict_passes_uses_tle_cache(self, mock_tle, mock_load):
|
||||||
|
"""predict_passes() should use live TLE cache if available."""
|
||||||
|
with patch('utils.weather_sat_predict._tle_cache', {'NOAA-18': ('NOAA-18', 'line1', 'line2')}):
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
mock_ts.now.return_value = MagicMock()
|
||||||
|
mock_ts.utc.return_value = MagicMock()
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
# Even though TLE_SATELLITES is mocked, should use _tle_cache
|
||||||
|
with patch('utils.weather_sat_predict.wgs84'), \
|
||||||
|
patch('utils.weather_sat_predict.EarthSatellite'), \
|
||||||
|
patch('utils.weather_sat_predict.find_discrete', return_value=([], [])):
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
# Should not raise
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_predict_passes_sorted_by_time(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""predict_passes() should return passes sorted by start time."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
# Two passes
|
||||||
|
rise1 = MagicMock()
|
||||||
|
rise1.utc_datetime.return_value = now + timedelta(hours=4)
|
||||||
|
set1 = MagicMock()
|
||||||
|
set1.utc_datetime.return_value = now + timedelta(hours=4, minutes=15)
|
||||||
|
rise2 = MagicMock()
|
||||||
|
rise2.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set2 = MagicMock()
|
||||||
|
set2.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
# Return in non-chronological order
|
||||||
|
mock_find.return_value = ([rise1, set1, rise2, set2], [True, False, True, False])
|
||||||
|
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 45.0
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
# Should be sorted with earliest pass first
|
||||||
|
if len(passes) >= 2:
|
||||||
|
assert passes[0]['startTimeISO'] < passes[1]['startTimeISO']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mock_time(dt):
|
||||||
|
"""Helper to create mock time object."""
|
||||||
|
mock_t = MagicMock()
|
||||||
|
if isinstance(dt, datetime):
|
||||||
|
mock_t.utc_datetime.return_value = dt
|
||||||
|
else:
|
||||||
|
mock_t.utc_datetime.return_value = datetime.now(timezone.utc)
|
||||||
|
return mock_t
|
||||||
|
|
||||||
|
|
||||||
|
class TestPassDataStructure:
|
||||||
|
"""Tests for pass data structure."""
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.load')
|
||||||
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
||||||
|
@patch('utils.weather_sat_predict.find_discrete')
|
||||||
|
def test_pass_data_fields(
|
||||||
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
||||||
|
):
|
||||||
|
"""Pass data should contain all required fields."""
|
||||||
|
mock_ts = MagicMock()
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
mock_now = MagicMock()
|
||||||
|
mock_now.utc_datetime.return_value = now
|
||||||
|
mock_ts.now.return_value = mock_now
|
||||||
|
mock_ts.utc.side_effect = lambda dt: TestPredictPasses._mock_time(dt)
|
||||||
|
mock_load.timescale.return_value = mock_ts
|
||||||
|
|
||||||
|
mock_tle.get.return_value = (
|
||||||
|
'NOAA-18',
|
||||||
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
||||||
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_observer = MagicMock()
|
||||||
|
mock_wgs84.latlon.return_value = mock_observer
|
||||||
|
|
||||||
|
mock_satellite_obj = MagicMock()
|
||||||
|
mock_sat.return_value = mock_satellite_obj
|
||||||
|
|
||||||
|
rise_time = MagicMock()
|
||||||
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
||||||
|
set_time = MagicMock()
|
||||||
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
||||||
|
|
||||||
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
||||||
|
|
||||||
|
def mock_topocentric(t):
|
||||||
|
topo = MagicMock()
|
||||||
|
alt = MagicMock()
|
||||||
|
alt.degrees = 45.0
|
||||||
|
az = MagicMock()
|
||||||
|
az.degrees = 180.0
|
||||||
|
topo.altaz.return_value = (alt, az, MagicMock())
|
||||||
|
return topo
|
||||||
|
|
||||||
|
mock_diff = MagicMock()
|
||||||
|
mock_diff.at.side_effect = mock_topocentric
|
||||||
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
||||||
|
|
||||||
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
pass_data = passes[0]
|
||||||
|
|
||||||
|
# Check all required fields
|
||||||
|
required_fields = [
|
||||||
|
'id', 'satellite', 'name', 'frequency', 'mode',
|
||||||
|
'startTime', 'startTimeISO', 'endTimeISO',
|
||||||
|
'maxEl', 'maxElAz', 'riseAz', 'setAz',
|
||||||
|
'duration', 'quality'
|
||||||
|
]
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in pass_data, f"Missing required field: {field}"
|
||||||
|
|
||||||
|
def test_import_error_propagates(self):
|
||||||
|
"""predict_passes() should raise ImportError if skyfield unavailable."""
|
||||||
|
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
|
||||||
|
with pytest.raises((ImportError, AttributeError)):
|
||||||
|
predict_passes(lat=51.5, lon=-0.1)
|
||||||
@@ -0,0 +1,801 @@
|
|||||||
|
"""Tests for weather satellite routes.
|
||||||
|
|
||||||
|
Covers all weather_sat endpoints: /status, /satellites, /start, /test-decode,
|
||||||
|
/stop, /images, /passes, and scheduler endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock, mock_open
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.weather_sat import WeatherSatImage, WEATHER_SATELLITES
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatRoutes:
|
||||||
|
"""Tests for weather satellite routes."""
|
||||||
|
|
||||||
|
def test_get_status(self, client):
|
||||||
|
"""GET /weather-sat/status returns decoder status."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.get_status.return_value = {
|
||||||
|
'available': True,
|
||||||
|
'decoder': 'satdump',
|
||||||
|
'running': False,
|
||||||
|
'satellite': '',
|
||||||
|
'frequency': 0.0,
|
||||||
|
'mode': '',
|
||||||
|
'elapsed_seconds': 0,
|
||||||
|
'image_count': 0,
|
||||||
|
}
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/status')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['available'] is True
|
||||||
|
assert data['decoder'] == 'satdump'
|
||||||
|
assert data['running'] is False
|
||||||
|
|
||||||
|
def test_list_satellites(self, client):
|
||||||
|
"""GET /weather-sat/satellites returns satellite list."""
|
||||||
|
response = client.get('/weather-sat/satellites')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert 'satellites' in data
|
||||||
|
assert len(data['satellites']) > 0
|
||||||
|
|
||||||
|
# Check structure
|
||||||
|
sat = data['satellites'][0]
|
||||||
|
assert 'key' in sat
|
||||||
|
assert 'name' in sat
|
||||||
|
assert 'frequency' in sat
|
||||||
|
assert 'mode' in sat
|
||||||
|
assert 'description' in sat
|
||||||
|
assert 'active' in sat
|
||||||
|
|
||||||
|
# Verify NOAA-18 is in list
|
||||||
|
noaa_18 = next((s for s in data['satellites'] if s['key'] == 'NOAA-18'), None)
|
||||||
|
assert noaa_18 is not None
|
||||||
|
assert noaa_18['frequency'] == 137.9125
|
||||||
|
assert noaa_18['mode'] == 'APT'
|
||||||
|
|
||||||
|
def test_start_capture_success(self, client):
|
||||||
|
"""POST /weather-sat/start successfully starts capture."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('routes.weather_sat.queue.Queue') as mock_queue:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = True
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'device': 0,
|
||||||
|
'gain': 40.0,
|
||||||
|
'bias_t': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'started'
|
||||||
|
assert data['satellite'] == 'NOAA-18'
|
||||||
|
assert data['frequency'] == 137.9125
|
||||||
|
assert data['mode'] == 'APT'
|
||||||
|
assert data['device'] == 0
|
||||||
|
|
||||||
|
mock_decoder.start.assert_called_once_with(
|
||||||
|
satellite='NOAA-18',
|
||||||
|
device_index=0,
|
||||||
|
gain=40.0,
|
||||||
|
bias_t=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_start_capture_no_satdump(self, client):
|
||||||
|
"""POST /weather-sat/start returns error when SatDump unavailable."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=False):
|
||||||
|
payload = {'satellite': 'NOAA-18'}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'SatDump not installed' in data['message']
|
||||||
|
|
||||||
|
def test_start_capture_already_running(self, client):
|
||||||
|
"""POST /weather-sat/start when already running."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = True
|
||||||
|
mock_decoder.current_satellite = 'NOAA-19'
|
||||||
|
mock_decoder.current_frequency = 137.100
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {'satellite': 'NOAA-18'}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'already_running'
|
||||||
|
assert data['satellite'] == 'NOAA-19'
|
||||||
|
|
||||||
|
def test_start_capture_invalid_satellite(self, client):
|
||||||
|
"""POST /weather-sat/start with invalid satellite."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {'satellite': 'FAKE-SAT-99'}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'Invalid satellite' in data['message']
|
||||||
|
|
||||||
|
def test_start_capture_invalid_device(self, client):
|
||||||
|
"""POST /weather-sat/start with invalid device index."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {'satellite': 'NOAA-18', 'device': -1}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
def test_start_capture_invalid_gain(self, client):
|
||||||
|
"""POST /weather-sat/start with invalid gain."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {'satellite': 'NOAA-18', 'gain': 999}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
def test_start_capture_device_busy(self, client):
|
||||||
|
"""POST /weather-sat/start when SDR device is busy."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('app.claim_sdr_device', return_value='Device busy with pager') as mock_claim:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {'satellite': 'NOAA-18'}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert data['error_type'] == 'DEVICE_BUSY'
|
||||||
|
assert 'Device busy' in data['message']
|
||||||
|
|
||||||
|
def test_start_capture_start_failure(self, client):
|
||||||
|
"""POST /weather-sat/start when decoder.start() fails."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {'satellite': 'NOAA-18'}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'Failed to start capture' in data['message']
|
||||||
|
|
||||||
|
def test_test_decode_success(self, client):
|
||||||
|
"""POST /weather-sat/test-decode successfully starts file decode."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('pathlib.Path.is_file', return_value=True), \
|
||||||
|
patch('pathlib.Path.resolve') as mock_resolve:
|
||||||
|
|
||||||
|
# Mock path resolution to be under data/
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.is_relative_to.return_value = True
|
||||||
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start_from_file.return_value = True
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'input_file': 'data/weather_sat/test.wav',
|
||||||
|
'sample_rate': 1000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/test-decode',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'started'
|
||||||
|
assert data['satellite'] == 'NOAA-18'
|
||||||
|
assert data['source'] == 'file'
|
||||||
|
|
||||||
|
def test_test_decode_invalid_path(self, client):
|
||||||
|
"""POST /weather-sat/test-decode with path outside data/."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('pathlib.Path.resolve') as mock_resolve:
|
||||||
|
|
||||||
|
# Mock path outside allowed directory
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.is_relative_to.return_value = False
|
||||||
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'input_file': '/etc/passwd',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/test-decode',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'data/ directory' in data['message']
|
||||||
|
|
||||||
|
def test_test_decode_file_not_found(self, client):
|
||||||
|
"""POST /weather-sat/test-decode with non-existent file."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('pathlib.Path.is_file', return_value=False), \
|
||||||
|
patch('pathlib.Path.resolve') as mock_resolve:
|
||||||
|
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.is_relative_to.return_value = True
|
||||||
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'input_file': 'data/missing.wav',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/test-decode',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'not found' in data['message'].lower()
|
||||||
|
|
||||||
|
def test_test_decode_invalid_sample_rate(self, client):
|
||||||
|
"""POST /weather-sat/test-decode with invalid sample rate."""
|
||||||
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'input_file': 'data/test.wav',
|
||||||
|
'sample_rate': 100, # Too low
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/test-decode',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'sample_rate' in data['message']
|
||||||
|
|
||||||
|
def test_stop_capture(self, client):
|
||||||
|
"""POST /weather-sat/stop stops capture."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.device_index = 0
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.post('/weather-sat/stop')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'stopped'
|
||||||
|
mock_decoder.stop.assert_called_once()
|
||||||
|
|
||||||
|
def test_list_images_empty(self, client):
|
||||||
|
"""GET /weather-sat/images with no images."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.get_images.return_value = []
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['images'] == []
|
||||||
|
assert data['count'] == 0
|
||||||
|
|
||||||
|
def test_list_images_with_data(self, client):
|
||||||
|
"""GET /weather-sat/images with images."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
image = WeatherSatImage(
|
||||||
|
filename='NOAA-18_test.png',
|
||||||
|
path=Path('/tmp/test.png'),
|
||||||
|
satellite='NOAA-18',
|
||||||
|
mode='APT',
|
||||||
|
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
frequency=137.9125,
|
||||||
|
size_bytes=12345,
|
||||||
|
product='RGB Composite',
|
||||||
|
)
|
||||||
|
mock_decoder.get_images.return_value = [image]
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['count'] == 1
|
||||||
|
assert data['images'][0]['filename'] == 'NOAA-18_test.png'
|
||||||
|
assert data['images'][0]['satellite'] == 'NOAA-18'
|
||||||
|
|
||||||
|
def test_list_images_with_filter(self, client):
|
||||||
|
"""GET /weather-sat/images with satellite filter."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
image1 = WeatherSatImage(
|
||||||
|
filename='NOAA-18_test.png',
|
||||||
|
path=Path('/tmp/test1.png'),
|
||||||
|
satellite='NOAA-18',
|
||||||
|
mode='APT',
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=137.9125,
|
||||||
|
)
|
||||||
|
image2 = WeatherSatImage(
|
||||||
|
filename='NOAA-19_test.png',
|
||||||
|
path=Path('/tmp/test2.png'),
|
||||||
|
satellite='NOAA-19',
|
||||||
|
mode='APT',
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=137.100,
|
||||||
|
)
|
||||||
|
mock_decoder.get_images.return_value = [image1, image2]
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images?satellite=NOAA-18')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['count'] == 1
|
||||||
|
assert data['images'][0]['satellite'] == 'NOAA-18'
|
||||||
|
|
||||||
|
def test_list_images_with_limit(self, client):
|
||||||
|
"""GET /weather-sat/images with limit."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
images = [
|
||||||
|
WeatherSatImage(
|
||||||
|
filename=f'test{i}.png',
|
||||||
|
path=Path(f'/tmp/test{i}.png'),
|
||||||
|
satellite='NOAA-18',
|
||||||
|
mode='APT',
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=137.9125,
|
||||||
|
)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
mock_decoder.get_images.return_value = images
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images?limit=5')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['count'] == 5
|
||||||
|
|
||||||
|
def test_get_image_success(self, client):
|
||||||
|
"""GET /weather-sat/images/<filename> serves image."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('routes.weather_sat.send_file') as mock_send, \
|
||||||
|
patch('pathlib.Path.exists', return_value=True):
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder._output_dir = Path('/tmp')
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
mock_send.return_value = MagicMock()
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images/test_image.png')
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
call_args = mock_send.call_args
|
||||||
|
assert call_args[1]['mimetype'] == 'image/png'
|
||||||
|
|
||||||
|
def test_get_image_invalid_filename(self, client):
|
||||||
|
"""GET /weather-sat/images/<filename> with invalid filename."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images/../../../etc/passwd')
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'Invalid filename' in data['message']
|
||||||
|
|
||||||
|
def test_get_image_wrong_extension(self, client):
|
||||||
|
"""GET /weather-sat/images/<filename> with wrong extension."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images/test.txt')
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'PNG/JPG' in data['message']
|
||||||
|
|
||||||
|
def test_get_image_not_found(self, client):
|
||||||
|
"""GET /weather-sat/images/<filename> for non-existent image."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('pathlib.Path.exists', return_value=False):
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder._output_dir = Path('/tmp')
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/images/missing.png')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_image_success(self, client):
|
||||||
|
"""DELETE /weather-sat/images/<filename> deletes image."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.delete_image.return_value = True
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.delete('/weather-sat/images/test.png')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'deleted'
|
||||||
|
assert data['filename'] == 'test.png'
|
||||||
|
|
||||||
|
def test_delete_image_not_found(self, client):
|
||||||
|
"""DELETE /weather-sat/images/<filename> for non-existent image."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.delete_image.return_value = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.delete('/weather-sat/images/missing.png')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_all_images(self, client):
|
||||||
|
"""DELETE /weather-sat/images deletes all images."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.delete_all_images.return_value = 5
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
response = client.delete('/weather-sat/images')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['deleted'] == 5
|
||||||
|
|
||||||
|
def test_stream_progress(self, client):
|
||||||
|
"""GET /weather-sat/stream returns SSE stream."""
|
||||||
|
response = client.get('/weather-sat/stream')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == 'text/event-stream'
|
||||||
|
assert response.headers['Cache-Control'] == 'no-cache'
|
||||||
|
|
||||||
|
def test_get_passes_missing_params(self, client):
|
||||||
|
"""GET /weather-sat/passes without required params."""
|
||||||
|
response = client.get('/weather-sat/passes')
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'latitude and longitude' in data['message']
|
||||||
|
|
||||||
|
def test_get_passes_invalid_coords(self, client):
|
||||||
|
"""GET /weather-sat/passes with invalid coordinates."""
|
||||||
|
response = client.get('/weather-sat/passes?latitude=999&longitude=0')
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
def test_get_passes_success(self, client):
|
||||||
|
"""GET /weather-sat/passes successfully predicts passes."""
|
||||||
|
with patch('routes.weather_sat.predict_passes') as mock_predict:
|
||||||
|
mock_predict.return_value = [
|
||||||
|
{
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTime': '2024-01-01 12:00 UTC',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'maxElAz': 180.0,
|
||||||
|
'riseAz': 160.0,
|
||||||
|
'setAz': 200.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['count'] == 1
|
||||||
|
assert data['passes'][0]['satellite'] == 'NOAA-18'
|
||||||
|
|
||||||
|
def test_get_passes_with_options(self, client):
|
||||||
|
"""GET /weather-sat/passes with trajectory and ground track."""
|
||||||
|
with patch('routes.weather_sat.predict_passes') as mock_predict:
|
||||||
|
mock_predict.return_value = []
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
'/weather-sat/passes?latitude=51.5&longitude=-0.1&'
|
||||||
|
'hours=48&min_elevation=20&trajectory=true&ground_track=true'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
mock_predict.assert_called_once()
|
||||||
|
call_kwargs = mock_predict.call_args[1]
|
||||||
|
assert call_kwargs['lat'] == 51.5
|
||||||
|
assert call_kwargs['lon'] == -0.1
|
||||||
|
assert call_kwargs['hours'] == 48
|
||||||
|
assert call_kwargs['min_elevation'] == 20.0
|
||||||
|
assert call_kwargs['include_trajectory'] is True
|
||||||
|
assert call_kwargs['include_ground_track'] is True
|
||||||
|
|
||||||
|
def test_get_passes_import_error(self, client):
|
||||||
|
"""GET /weather-sat/passes when skyfield not installed."""
|
||||||
|
with patch('routes.weather_sat.predict_passes', side_effect=ImportError):
|
||||||
|
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
|
||||||
|
assert response.status_code == 503
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'skyfield' in data['message']
|
||||||
|
|
||||||
|
def test_get_passes_prediction_error(self, client):
|
||||||
|
"""GET /weather-sat/passes when prediction fails."""
|
||||||
|
with patch('routes.weather_sat.predict_passes', side_effect=Exception('TLE error')):
|
||||||
|
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
|
||||||
|
assert response.status_code == 500
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatScheduler:
|
||||||
|
"""Tests for weather satellite scheduler endpoints."""
|
||||||
|
|
||||||
|
def test_enable_schedule_success(self, client):
|
||||||
|
"""POST /weather-sat/schedule/enable enables scheduler."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
||||||
|
mock_scheduler = MagicMock()
|
||||||
|
mock_scheduler.enable.return_value = {
|
||||||
|
'enabled': True,
|
||||||
|
'observer': {'latitude': 51.5, 'longitude': -0.1},
|
||||||
|
'device': 0,
|
||||||
|
'gain': 40.0,
|
||||||
|
'bias_t': False,
|
||||||
|
'min_elevation': 15.0,
|
||||||
|
'scheduled_count': 3,
|
||||||
|
'total_passes': 3,
|
||||||
|
}
|
||||||
|
mock_get.return_value = mock_scheduler
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'latitude': 51.5,
|
||||||
|
'longitude': -0.1,
|
||||||
|
'min_elevation': 15,
|
||||||
|
'device': 0,
|
||||||
|
'gain': 40.0,
|
||||||
|
'bias_t': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/schedule/enable',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['enabled'] is True
|
||||||
|
|
||||||
|
def test_enable_schedule_missing_coords(self, client):
|
||||||
|
"""POST /weather-sat/schedule/enable without coordinates."""
|
||||||
|
payload = {'device': 0}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/schedule/enable',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'latitude and longitude' in data['message']
|
||||||
|
|
||||||
|
def test_enable_schedule_invalid_coords(self, client):
|
||||||
|
"""POST /weather-sat/schedule/enable with invalid coordinates."""
|
||||||
|
payload = {'latitude': 999, 'longitude': 0}
|
||||||
|
response = client.post(
|
||||||
|
'/weather-sat/schedule/enable',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
def test_disable_schedule(self, client):
|
||||||
|
"""POST /weather-sat/schedule/disable disables scheduler."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
||||||
|
mock_scheduler = MagicMock()
|
||||||
|
mock_scheduler.disable.return_value = {'status': 'disabled'}
|
||||||
|
mock_get.return_value = mock_scheduler
|
||||||
|
|
||||||
|
response = client.post('/weather-sat/schedule/disable')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'disabled'
|
||||||
|
|
||||||
|
def test_schedule_status(self, client):
|
||||||
|
"""GET /weather-sat/schedule/status returns scheduler status."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
||||||
|
mock_scheduler = MagicMock()
|
||||||
|
mock_scheduler.get_status.return_value = {
|
||||||
|
'enabled': False,
|
||||||
|
'observer': {'latitude': 0, 'longitude': 0},
|
||||||
|
'device': 0,
|
||||||
|
'gain': 40.0,
|
||||||
|
'bias_t': False,
|
||||||
|
'min_elevation': 15.0,
|
||||||
|
'scheduled_count': 0,
|
||||||
|
'total_passes': 0,
|
||||||
|
}
|
||||||
|
mock_get.return_value = mock_scheduler
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/schedule/status')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'enabled' in data
|
||||||
|
|
||||||
|
def test_schedule_passes(self, client):
|
||||||
|
"""GET /weather-sat/schedule/passes lists scheduled passes."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
||||||
|
mock_scheduler = MagicMock()
|
||||||
|
mock_scheduler.get_passes.return_value = [
|
||||||
|
{
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'status': 'scheduled',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_get.return_value = mock_scheduler
|
||||||
|
|
||||||
|
response = client.get('/weather-sat/schedule/passes')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['count'] == 1
|
||||||
|
|
||||||
|
def test_skip_pass_success(self, client):
|
||||||
|
"""POST /weather-sat/schedule/skip/<id> skips a pass."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
||||||
|
mock_scheduler = MagicMock()
|
||||||
|
mock_scheduler.skip_pass.return_value = True
|
||||||
|
mock_get.return_value = mock_scheduler
|
||||||
|
|
||||||
|
response = client.post('/weather-sat/schedule/skip/NOAA-18_202401011200')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'skipped'
|
||||||
|
assert data['pass_id'] == 'NOAA-18_202401011200'
|
||||||
|
|
||||||
|
def test_skip_pass_not_found(self, client):
|
||||||
|
"""POST /weather-sat/schedule/skip/<id> for non-existent pass."""
|
||||||
|
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
||||||
|
mock_scheduler = MagicMock()
|
||||||
|
mock_scheduler.skip_pass.return_value = False
|
||||||
|
mock_get.return_value = mock_scheduler
|
||||||
|
|
||||||
|
response = client.post('/weather-sat/schedule/skip/nonexistent')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_skip_pass_invalid_id(self, client):
|
||||||
|
"""POST /weather-sat/schedule/skip/<id> with invalid ID."""
|
||||||
|
response = client.post('/weather-sat/schedule/skip/../../../etc/passwd')
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'Invalid pass ID' in data['message']
|
||||||
@@ -0,0 +1,779 @@
|
|||||||
|
"""Tests for weather satellite auto-scheduler.
|
||||||
|
|
||||||
|
Covers WeatherSatScheduler class, pass scheduling, timer management,
|
||||||
|
and automatic capture execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from unittest.mock import patch, MagicMock, call
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.weather_sat_scheduler import (
|
||||||
|
WeatherSatScheduler,
|
||||||
|
ScheduledPass,
|
||||||
|
get_weather_sat_scheduler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduledPass:
|
||||||
|
"""Tests for ScheduledPass class."""
|
||||||
|
|
||||||
|
def test_scheduled_pass_initialization(self):
|
||||||
|
"""ScheduledPass should initialize from pass data."""
|
||||||
|
pass_data = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
assert sp.id == 'NOAA-18_202401011200'
|
||||||
|
assert sp.satellite == 'NOAA-18'
|
||||||
|
assert sp.name == 'NOAA 18'
|
||||||
|
assert sp.frequency == 137.9125
|
||||||
|
assert sp.mode == 'APT'
|
||||||
|
assert sp.max_el == 45.0
|
||||||
|
assert sp.duration == 15.0
|
||||||
|
assert sp.quality == 'good'
|
||||||
|
assert sp.status == 'scheduled'
|
||||||
|
assert sp.skipped is False
|
||||||
|
|
||||||
|
def test_scheduled_pass_start_dt(self):
|
||||||
|
"""ScheduledPass.start_dt should parse ISO datetime."""
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
assert sp.start_dt.year == 2024
|
||||||
|
assert sp.start_dt.month == 1
|
||||||
|
assert sp.start_dt.day == 1
|
||||||
|
assert sp.start_dt.hour == 12
|
||||||
|
assert sp.start_dt.tzinfo == timezone.utc
|
||||||
|
|
||||||
|
def test_scheduled_pass_end_dt(self):
|
||||||
|
"""ScheduledPass.end_dt should parse ISO datetime."""
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
assert sp.end_dt.year == 2024
|
||||||
|
assert sp.end_dt.minute == 15
|
||||||
|
|
||||||
|
def test_scheduled_pass_to_dict(self):
|
||||||
|
"""ScheduledPass.to_dict() should serialize correctly."""
|
||||||
|
pass_data = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
sp.status = 'complete'
|
||||||
|
|
||||||
|
data = sp.to_dict()
|
||||||
|
|
||||||
|
assert data['id'] == 'NOAA-18_202401011200'
|
||||||
|
assert data['satellite'] == 'NOAA-18'
|
||||||
|
assert data['status'] == 'complete'
|
||||||
|
assert data['skipped'] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherSatScheduler:
|
||||||
|
"""Tests for WeatherSatScheduler class."""
|
||||||
|
|
||||||
|
def test_scheduler_initialization(self):
|
||||||
|
"""Scheduler should initialize with defaults."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
|
||||||
|
assert scheduler.enabled is False
|
||||||
|
assert scheduler._lat == 0.0
|
||||||
|
assert scheduler._lon == 0.0
|
||||||
|
assert scheduler._min_elevation == 15.0
|
||||||
|
assert scheduler._device == 0
|
||||||
|
assert scheduler._gain == 40.0
|
||||||
|
assert scheduler._bias_t is False
|
||||||
|
assert scheduler._passes == []
|
||||||
|
|
||||||
|
def test_set_callbacks(self):
|
||||||
|
"""Scheduler should accept callbacks."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
progress_cb = MagicMock()
|
||||||
|
event_cb = MagicMock()
|
||||||
|
|
||||||
|
scheduler.set_callbacks(progress_cb, event_cb)
|
||||||
|
|
||||||
|
assert scheduler._progress_callback == progress_cb
|
||||||
|
assert scheduler._event_callback == event_cb
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.WeatherSatScheduler._refresh_passes')
|
||||||
|
def test_enable(self, mock_refresh):
|
||||||
|
"""enable() should start scheduler."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
|
||||||
|
result = scheduler.enable(
|
||||||
|
lat=51.5,
|
||||||
|
lon=-0.1,
|
||||||
|
min_elevation=20.0,
|
||||||
|
device=1,
|
||||||
|
gain=35.0,
|
||||||
|
bias_t=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scheduler._enabled is True
|
||||||
|
assert scheduler._lat == 51.5
|
||||||
|
assert scheduler._lon == -0.1
|
||||||
|
assert scheduler._min_elevation == 20.0
|
||||||
|
assert scheduler._device == 1
|
||||||
|
assert scheduler._gain == 35.0
|
||||||
|
assert scheduler._bias_t is True
|
||||||
|
mock_refresh.assert_called_once()
|
||||||
|
assert 'enabled' in result
|
||||||
|
|
||||||
|
def test_disable(self):
|
||||||
|
"""disable() should stop scheduler and cancel timers."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
|
||||||
|
# Add mock timer
|
||||||
|
mock_timer = MagicMock()
|
||||||
|
scheduler._refresh_timer = mock_timer
|
||||||
|
|
||||||
|
# Add pass with timer
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
sp._timer = MagicMock()
|
||||||
|
sp._stop_timer = MagicMock()
|
||||||
|
scheduler._passes = [sp]
|
||||||
|
|
||||||
|
result = scheduler.disable()
|
||||||
|
|
||||||
|
assert scheduler._enabled is False
|
||||||
|
assert scheduler._passes == []
|
||||||
|
mock_timer.cancel.assert_called_once()
|
||||||
|
sp._timer.cancel.assert_called_once()
|
||||||
|
sp._stop_timer.cancel.assert_called_once()
|
||||||
|
assert result['status'] == 'disabled'
|
||||||
|
|
||||||
|
def test_skip_pass_success(self):
|
||||||
|
"""skip_pass() should skip a scheduled pass."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
event_cb = MagicMock()
|
||||||
|
scheduler.set_callbacks(MagicMock(), event_cb)
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
sp._timer = MagicMock()
|
||||||
|
scheduler._passes = [sp]
|
||||||
|
|
||||||
|
result = scheduler.skip_pass('NOAA-18_202401011200')
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert sp.status == 'skipped'
|
||||||
|
assert sp.skipped is True
|
||||||
|
sp._timer.cancel.assert_called_once()
|
||||||
|
event_cb.assert_called_once()
|
||||||
|
|
||||||
|
def test_skip_pass_not_found(self):
|
||||||
|
"""skip_pass() should return False for non-existent pass."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
|
||||||
|
result = scheduler.skip_pass('NONEXISTENT')
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_skip_pass_already_complete(self):
|
||||||
|
"""skip_pass() should not skip already complete passes."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
sp.status = 'complete'
|
||||||
|
scheduler._passes = [sp]
|
||||||
|
|
||||||
|
result = scheduler.skip_pass('NOAA-18_202401011200')
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert sp.status == 'complete'
|
||||||
|
|
||||||
|
def test_get_status(self):
|
||||||
|
"""get_status() should return scheduler state."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._lat = 51.5
|
||||||
|
scheduler._lon = -0.1
|
||||||
|
scheduler._device = 0
|
||||||
|
scheduler._gain = 40.0
|
||||||
|
scheduler._bias_t = False
|
||||||
|
scheduler._min_elevation = 15.0
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
scheduler._passes = [sp]
|
||||||
|
|
||||||
|
status = scheduler.get_status()
|
||||||
|
|
||||||
|
assert status['enabled'] is True
|
||||||
|
assert status['observer']['latitude'] == 51.5
|
||||||
|
assert status['observer']['longitude'] == -0.1
|
||||||
|
assert status['device'] == 0
|
||||||
|
assert status['gain'] == 40.0
|
||||||
|
assert status['bias_t'] is False
|
||||||
|
assert status['min_elevation'] == 15.0
|
||||||
|
assert status['scheduled_count'] == 1
|
||||||
|
assert status['total_passes'] == 1
|
||||||
|
|
||||||
|
def test_get_passes(self):
|
||||||
|
"""get_passes() should return list of scheduled passes."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
scheduler._passes = [sp]
|
||||||
|
|
||||||
|
passes = scheduler.get_passes()
|
||||||
|
|
||||||
|
assert len(passes) == 1
|
||||||
|
assert passes[0]['id'] == 'NOAA-18_202401011200'
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.predict_passes')
|
||||||
|
@patch('threading.Timer')
|
||||||
|
def test_refresh_passes(self, mock_timer, mock_predict):
|
||||||
|
"""_refresh_passes() should schedule future passes."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
future_pass = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': (now + timedelta(hours=2)).isoformat(),
|
||||||
|
'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
mock_predict.return_value = [future_pass]
|
||||||
|
|
||||||
|
mock_timer_instance = MagicMock()
|
||||||
|
mock_timer.return_value = mock_timer_instance
|
||||||
|
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._lat = 51.5
|
||||||
|
scheduler._lon = -0.1
|
||||||
|
|
||||||
|
scheduler._refresh_passes()
|
||||||
|
|
||||||
|
mock_predict.assert_called_once()
|
||||||
|
assert len(scheduler._passes) == 1
|
||||||
|
assert scheduler._passes[0].satellite == 'NOAA-18'
|
||||||
|
mock_timer_instance.start.assert_called()
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.predict_passes')
|
||||||
|
def test_refresh_passes_skip_past(self, mock_predict):
|
||||||
|
"""_refresh_passes() should skip passes that already started."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
past_pass = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': (now - timedelta(hours=1)).isoformat(),
|
||||||
|
'endTimeISO': (now - timedelta(hours=1) + timedelta(minutes=15)).isoformat(),
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
mock_predict.return_value = [past_pass]
|
||||||
|
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._lat = 51.5
|
||||||
|
scheduler._lon = -0.1
|
||||||
|
|
||||||
|
scheduler._refresh_passes()
|
||||||
|
|
||||||
|
# Should not schedule past passes
|
||||||
|
assert len(scheduler._passes) == 0
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.predict_passes')
|
||||||
|
def test_refresh_passes_disabled(self, mock_predict):
|
||||||
|
"""_refresh_passes() should do nothing when disabled."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = False
|
||||||
|
|
||||||
|
scheduler._refresh_passes()
|
||||||
|
|
||||||
|
mock_predict.assert_not_called()
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.predict_passes')
|
||||||
|
def test_refresh_passes_error_handling(self, mock_predict):
|
||||||
|
"""_refresh_passes() should handle prediction errors."""
|
||||||
|
mock_predict.side_effect = Exception('TLE error')
|
||||||
|
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._lat = 51.5
|
||||||
|
scheduler._lon = -0.1
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
scheduler._refresh_passes()
|
||||||
|
|
||||||
|
assert len(scheduler._passes) == 0
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
|
def test_execute_capture_disabled(self, mock_get):
|
||||||
|
"""_execute_capture() should do nothing when disabled."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = False
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
scheduler._execute_capture(sp)
|
||||||
|
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
|
def test_execute_capture_skipped(self, mock_get):
|
||||||
|
"""_execute_capture() should do nothing for skipped passes."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
sp.skipped = True
|
||||||
|
|
||||||
|
scheduler._execute_capture(sp)
|
||||||
|
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
|
def test_execute_capture_decoder_busy(self, mock_get):
|
||||||
|
"""_execute_capture() should skip when decoder is busy."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
event_cb = MagicMock()
|
||||||
|
scheduler.set_callbacks(MagicMock(), event_cb)
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = True
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
scheduler._execute_capture(sp)
|
||||||
|
|
||||||
|
assert sp.status == 'skipped'
|
||||||
|
assert sp.skipped is True
|
||||||
|
event_cb.assert_called_once()
|
||||||
|
event_data = event_cb.call_args[0][0]
|
||||||
|
assert event_data['type'] == 'schedule_capture_skipped'
|
||||||
|
assert event_data['reason'] == 'sdr_busy'
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
|
@patch('threading.Timer')
|
||||||
|
def test_execute_capture_success(self, mock_timer, mock_get):
|
||||||
|
"""_execute_capture() should start capture."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._device = 0
|
||||||
|
scheduler._gain = 40.0
|
||||||
|
scheduler._bias_t = False
|
||||||
|
progress_cb = MagicMock()
|
||||||
|
event_cb = MagicMock()
|
||||||
|
scheduler.set_callbacks(progress_cb, event_cb)
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = True
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
mock_timer_instance = MagicMock()
|
||||||
|
mock_timer.return_value = mock_timer_instance
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': (now + timedelta(seconds=10)).isoformat(),
|
||||||
|
'endTimeISO': (now + timedelta(minutes=15)).isoformat(),
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
scheduler._execute_capture(sp)
|
||||||
|
|
||||||
|
assert sp.status == 'capturing'
|
||||||
|
mock_decoder.set_callback.assert_called_once_with(progress_cb)
|
||||||
|
mock_decoder.start.assert_called_once_with(
|
||||||
|
satellite='NOAA-18',
|
||||||
|
device_index=0,
|
||||||
|
gain=40.0,
|
||||||
|
bias_t=False,
|
||||||
|
)
|
||||||
|
event_cb.assert_called_once()
|
||||||
|
event_data = event_cb.call_args[0][0]
|
||||||
|
assert event_data['type'] == 'schedule_capture_start'
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
|
def test_execute_capture_start_failed(self, mock_get):
|
||||||
|
"""_execute_capture() should handle start failure."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
event_cb = MagicMock()
|
||||||
|
scheduler.set_callbacks(MagicMock(), event_cb)
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = False
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
scheduler._execute_capture(sp)
|
||||||
|
|
||||||
|
assert sp.status == 'skipped'
|
||||||
|
event_cb.assert_called_once()
|
||||||
|
event_data = event_cb.call_args[0][0]
|
||||||
|
assert event_data['reason'] == 'start_failed'
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
|
def test_stop_capture(self, mock_get):
|
||||||
|
"""_stop_capture() should stop decoder."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = True
|
||||||
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
scheduler._stop_capture(sp)
|
||||||
|
|
||||||
|
mock_decoder.stop.assert_called_once()
|
||||||
|
|
||||||
|
def test_on_capture_complete(self):
|
||||||
|
"""_on_capture_complete() should mark pass complete and emit event."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
event_cb = MagicMock()
|
||||||
|
scheduler.set_callbacks(MagicMock(), event_cb)
|
||||||
|
release_fn = MagicMock()
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': '2024-01-01T12:00:00+00:00',
|
||||||
|
'endTimeISO': '2024-01-01T12:15:00+00:00',
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
|
|
||||||
|
scheduler._on_capture_complete(sp, release_fn)
|
||||||
|
|
||||||
|
assert sp.status == 'complete'
|
||||||
|
release_fn.assert_called_once()
|
||||||
|
event_cb.assert_called_once()
|
||||||
|
event_data = event_cb.call_args[0][0]
|
||||||
|
assert event_data['type'] == 'schedule_capture_complete'
|
||||||
|
|
||||||
|
def test_emit_event(self):
|
||||||
|
"""_emit_event() should call event callback."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
event_cb = MagicMock()
|
||||||
|
scheduler.set_callbacks(MagicMock(), event_cb)
|
||||||
|
|
||||||
|
event = {'type': 'test_event', 'data': 'test'}
|
||||||
|
scheduler._emit_event(event)
|
||||||
|
|
||||||
|
event_cb.assert_called_once_with(event)
|
||||||
|
|
||||||
|
def test_emit_event_no_callback(self):
|
||||||
|
"""_emit_event() should handle missing callback."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
|
||||||
|
event = {'type': 'test_event'}
|
||||||
|
scheduler._emit_event(event) # Should not raise
|
||||||
|
|
||||||
|
def test_emit_event_callback_exception(self):
|
||||||
|
"""_emit_event() should handle callback exceptions."""
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
event_cb = MagicMock(side_effect=Exception('Callback error'))
|
||||||
|
scheduler.set_callbacks(MagicMock(), event_cb)
|
||||||
|
|
||||||
|
event = {'type': 'test_event'}
|
||||||
|
scheduler._emit_event(event) # Should not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalScheduler:
|
||||||
|
"""Tests for global scheduler singleton."""
|
||||||
|
|
||||||
|
def test_get_weather_sat_scheduler_singleton(self):
|
||||||
|
"""get_weather_sat_scheduler() should return singleton."""
|
||||||
|
import utils.weather_sat_scheduler as mod
|
||||||
|
old = mod._scheduler
|
||||||
|
mod._scheduler = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduler1 = get_weather_sat_scheduler()
|
||||||
|
scheduler2 = get_weather_sat_scheduler()
|
||||||
|
|
||||||
|
assert scheduler1 is scheduler2
|
||||||
|
finally:
|
||||||
|
mod._scheduler = old
|
||||||
|
|
||||||
|
def test_get_weather_sat_scheduler_thread_safe(self):
|
||||||
|
"""get_weather_sat_scheduler() should be thread-safe."""
|
||||||
|
import utils.weather_sat_scheduler as mod
|
||||||
|
old = mod._scheduler
|
||||||
|
mod._scheduler = None
|
||||||
|
|
||||||
|
schedulers = []
|
||||||
|
|
||||||
|
def create_scheduler():
|
||||||
|
schedulers.append(get_weather_sat_scheduler())
|
||||||
|
|
||||||
|
try:
|
||||||
|
threads = [threading.Thread(target=create_scheduler) for _ in range(10)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
# All should be the same instance
|
||||||
|
assert all(s is schedulers[0] for s in schedulers)
|
||||||
|
finally:
|
||||||
|
mod._scheduler = old
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedulerConfiguration:
|
||||||
|
"""Tests for scheduler configuration constants."""
|
||||||
|
|
||||||
|
def test_config_constants(self):
|
||||||
|
"""Scheduler should have configuration constants."""
|
||||||
|
from utils.weather_sat_scheduler import (
|
||||||
|
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
|
||||||
|
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, int)
|
||||||
|
assert isinstance(WEATHER_SAT_CAPTURE_BUFFER_SECONDS, int)
|
||||||
|
assert WEATHER_SAT_SCHEDULE_REFRESH_MINUTES > 0
|
||||||
|
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedulerIntegration:
|
||||||
|
"""Integration tests for scheduler."""
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_scheduler.predict_passes')
|
||||||
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
|
@patch('threading.Timer')
|
||||||
|
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
|
||||||
|
"""Test complete scheduling cycle from enable to execute."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
future_pass = {
|
||||||
|
'id': 'NOAA-18_202401011200',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': (now + timedelta(hours=2)).isoformat(),
|
||||||
|
'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 15.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
mock_predict.return_value = [future_pass]
|
||||||
|
|
||||||
|
mock_timer_instance = MagicMock()
|
||||||
|
mock_timer.return_value = mock_timer_instance
|
||||||
|
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = True
|
||||||
|
mock_get_decoder.return_value = mock_decoder
|
||||||
|
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
progress_cb = MagicMock()
|
||||||
|
event_cb = MagicMock()
|
||||||
|
scheduler.set_callbacks(progress_cb, event_cb)
|
||||||
|
|
||||||
|
# Enable scheduler
|
||||||
|
result = scheduler.enable(lat=51.5, lon=-0.1)
|
||||||
|
|
||||||
|
assert result['enabled'] is True
|
||||||
|
assert len(scheduler._passes) == 1
|
||||||
|
assert scheduler._passes[0].satellite == 'NOAA-18'
|
||||||
|
|
||||||
|
# Simulate timer firing (capture start)
|
||||||
|
scheduler._execute_capture(scheduler._passes[0])
|
||||||
|
|
||||||
|
assert scheduler._passes[0].status == 'capturing'
|
||||||
|
mock_decoder.start.assert_called_once()
|
||||||
|
|
||||||
|
# Simulate completion
|
||||||
|
release_fn = MagicMock()
|
||||||
|
scheduler._on_capture_complete(scheduler._passes[0], release_fn)
|
||||||
|
|
||||||
|
assert scheduler._passes[0].status == 'complete'
|
||||||
|
release_fn.assert_called_once()
|
||||||
|
|
||||||
|
# Disable scheduler
|
||||||
|
scheduler.disable()
|
||||||
|
|
||||||
|
assert scheduler.enabled is False
|
||||||
|
assert len(scheduler._passes) == 0
|
||||||
@@ -606,6 +606,12 @@ class DeviceAggregator:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_fingerprint_mac_count(self, fingerprint_id: str) -> int:
|
||||||
|
"""Return how many distinct device_ids share a fingerprint."""
|
||||||
|
with self._lock:
|
||||||
|
device_ids = self._fingerprint_to_devices.get(fingerprint_id)
|
||||||
|
return len(device_ids) if device_ids else 0
|
||||||
|
|
||||||
def prune_ring_buffer(self) -> int:
|
def prune_ring_buffer(self) -> int:
|
||||||
"""Prune old observations from ring buffer."""
|
"""Prune old observations from ring buffer."""
|
||||||
return self._ring_buffer.prune_old()
|
return self._ring_buffer.prune_old()
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ ADDRESS_TYPE_RANDOM = 'random'
|
|||||||
ADDRESS_TYPE_RANDOM_STATIC = 'random_static'
|
ADDRESS_TYPE_RANDOM_STATIC = 'random_static'
|
||||||
ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address
|
ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address
|
||||||
ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address
|
ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address
|
||||||
|
ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PROTOCOL TYPES
|
# PROTOCOL TYPES
|
||||||
@@ -278,3 +279,59 @@ MINOR_WEARABLE = {
|
|||||||
0x04: 'Helmet',
|
0x04: 'Helmet',
|
||||||
0x05: 'Glasses',
|
0x05: 'Glasses',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLE APPEARANCE CODES (GAP Appearance values)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
BLE_APPEARANCE_NAMES: dict[int, str] = {
|
||||||
|
0: 'Unknown',
|
||||||
|
64: 'Phone',
|
||||||
|
128: 'Computer',
|
||||||
|
192: 'Watch',
|
||||||
|
193: 'Sports Watch',
|
||||||
|
256: 'Clock',
|
||||||
|
320: 'Display',
|
||||||
|
384: 'Remote Control',
|
||||||
|
448: 'Eye Glasses',
|
||||||
|
512: 'Tag',
|
||||||
|
576: 'Keyring',
|
||||||
|
640: 'Media Player',
|
||||||
|
704: 'Barcode Scanner',
|
||||||
|
768: 'Thermometer',
|
||||||
|
832: 'Heart Rate Sensor',
|
||||||
|
896: 'Blood Pressure',
|
||||||
|
960: 'HID',
|
||||||
|
961: 'Keyboard',
|
||||||
|
962: 'Mouse',
|
||||||
|
963: 'Joystick',
|
||||||
|
964: 'Gamepad',
|
||||||
|
965: 'Digitizer Tablet',
|
||||||
|
966: 'Card Reader',
|
||||||
|
967: 'Digital Pen',
|
||||||
|
968: 'Barcode Scanner (HID)',
|
||||||
|
1024: 'Glucose Monitor',
|
||||||
|
1088: 'Running Speed Sensor',
|
||||||
|
1152: 'Cycling',
|
||||||
|
1216: 'Control Device',
|
||||||
|
1280: 'Network Device',
|
||||||
|
1344: 'Sensor',
|
||||||
|
1408: 'Light Fixture',
|
||||||
|
1472: 'Fan',
|
||||||
|
1536: 'HVAC',
|
||||||
|
1600: 'Access Control',
|
||||||
|
1664: 'Motorized Device',
|
||||||
|
1728: 'Power Device',
|
||||||
|
1792: 'Light Source',
|
||||||
|
3136: 'Pulse Oximeter',
|
||||||
|
3200: 'Weight Scale',
|
||||||
|
3264: 'Personal Mobility',
|
||||||
|
5184: 'Outdoor Sports Activity',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_appearance_name(code: int | None) -> str | None:
|
||||||
|
"""Look up a human-readable name for a BLE appearance code."""
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
return BLE_APPEARANCE_NAMES.get(code)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Optional
|
|||||||
from .constants import (
|
from .constants import (
|
||||||
ADDRESS_TYPE_PUBLIC,
|
ADDRESS_TYPE_PUBLIC,
|
||||||
ADDRESS_TYPE_RANDOM_STATIC,
|
ADDRESS_TYPE_RANDOM_STATIC,
|
||||||
|
ADDRESS_TYPE_UUID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,10 +47,14 @@ def generate_device_key(
|
|||||||
if identity_address:
|
if identity_address:
|
||||||
return f"id:{identity_address.upper()}"
|
return f"id:{identity_address.upper()}"
|
||||||
|
|
||||||
# Priority 2: Use public or random_static addresses directly
|
# Priority 2: Use public or random_static addresses directly (not platform UUIDs)
|
||||||
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
|
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
|
||||||
return f"mac:{address.upper()}"
|
return f"mac:{address.upper()}"
|
||||||
|
|
||||||
|
# Priority 2b: CoreBluetooth UUIDs are stable per-system, use as identifier
|
||||||
|
if address_type == ADDRESS_TYPE_UUID:
|
||||||
|
return f"uuid:{address.upper()}"
|
||||||
|
|
||||||
# Priority 3: Generate fingerprint hash for random addresses
|
# Priority 3: Generate fingerprint hash for random addresses
|
||||||
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
|
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ def is_randomized_mac(address_type: str) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if the address is randomized, False otherwise.
|
True if the address is randomized, False otherwise.
|
||||||
"""
|
"""
|
||||||
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC)
|
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_UUID)
|
||||||
|
|
||||||
|
|
||||||
def extract_key_type(device_key: str) -> str:
|
def extract_key_type(device_key: str) -> str:
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ from .constants import (
|
|||||||
BLUETOOTHCTL_TIMEOUT,
|
BLUETOOTHCTL_TIMEOUT,
|
||||||
ADDRESS_TYPE_PUBLIC,
|
ADDRESS_TYPE_PUBLIC,
|
||||||
ADDRESS_TYPE_RANDOM,
|
ADDRESS_TYPE_RANDOM,
|
||||||
|
ADDRESS_TYPE_UUID,
|
||||||
MANUFACTURER_NAMES,
|
MANUFACTURER_NAMES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits
|
||||||
|
_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')
|
||||||
from .models import BTObservation
|
from .models import BTObservation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -132,7 +136,10 @@ class BleakScanner:
|
|||||||
"""Convert bleak device to BTObservation."""
|
"""Convert bleak device to BTObservation."""
|
||||||
# Determine address type from address format
|
# Determine address type from address format
|
||||||
address_type = ADDRESS_TYPE_PUBLIC
|
address_type = ADDRESS_TYPE_PUBLIC
|
||||||
if device.address and ':' in device.address:
|
if device.address and _CB_UUID_RE.match(device.address):
|
||||||
|
# macOS CoreBluetooth returns a platform UUID instead of a real MAC
|
||||||
|
address_type = ADDRESS_TYPE_UUID
|
||||||
|
elif device.address and ':' in device.address:
|
||||||
# Check if first byte indicates random address
|
# Check if first byte indicates random address
|
||||||
first_byte = int(device.address.split(':')[0], 16)
|
first_byte = int(device.address.split(':')[0], 16)
|
||||||
if (first_byte & 0xC0) == 0xC0: # Random static
|
if (first_byte & 0xC0) == 0xC0: # Random static
|
||||||
|
|||||||
@@ -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
|
||||||
+45
-13
@@ -18,6 +18,7 @@ from .constants import (
|
|||||||
RANGE_UNKNOWN,
|
RANGE_UNKNOWN,
|
||||||
PROTOCOL_BLE,
|
PROTOCOL_BLE,
|
||||||
PROXIMITY_UNKNOWN,
|
PROXIMITY_UNKNOWN,
|
||||||
|
get_appearance_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import tracker types (will be available after tracker_signatures module loads)
|
# Import tracker types (will be available after tracker_signatures module loads)
|
||||||
@@ -148,10 +149,10 @@ class BTDeviceAggregate:
|
|||||||
is_strong_stable: bool = False
|
is_strong_stable: bool = False
|
||||||
has_random_address: bool = False
|
has_random_address: bool = False
|
||||||
|
|
||||||
# Baseline tracking
|
# Baseline tracking
|
||||||
in_baseline: bool = False
|
in_baseline: bool = False
|
||||||
baseline_id: Optional[int] = None
|
baseline_id: Optional[int] = None
|
||||||
seen_before: bool = False
|
seen_before: bool = False
|
||||||
|
|
||||||
# Tracker detection fields
|
# Tracker detection fields
|
||||||
is_tracker: bool = False
|
is_tracker: bool = False
|
||||||
@@ -165,6 +166,10 @@ class BTDeviceAggregate:
|
|||||||
risk_score: float = 0.0 # 0.0 to 1.0
|
risk_score: float = 0.0 # 0.0 to 1.0
|
||||||
risk_factors: list[str] = field(default_factory=list)
|
risk_factors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# IRK (Identity Resolving Key) from paired device database
|
||||||
|
irk_hex: Optional[str] = None # 32-char hex if known
|
||||||
|
irk_source_name: Optional[str] = None # Name from paired DB
|
||||||
|
|
||||||
# Payload fingerprint (survives MAC randomization)
|
# Payload fingerprint (survives MAC randomization)
|
||||||
payload_fingerprint_id: Optional[str] = None
|
payload_fingerprint_id: Optional[str] = None
|
||||||
payload_fingerprint_stability: float = 0.0
|
payload_fingerprint_stability: float = 0.0
|
||||||
@@ -275,10 +280,10 @@ class BTDeviceAggregate:
|
|||||||
},
|
},
|
||||||
'heuristic_flags': self.heuristic_flags,
|
'heuristic_flags': self.heuristic_flags,
|
||||||
|
|
||||||
# Baseline
|
# Baseline
|
||||||
'in_baseline': self.in_baseline,
|
'in_baseline': self.in_baseline,
|
||||||
'baseline_id': self.baseline_id,
|
'baseline_id': self.baseline_id,
|
||||||
'seen_before': self.seen_before,
|
'seen_before': self.seen_before,
|
||||||
|
|
||||||
# Tracker detection
|
# Tracker detection
|
||||||
'tracker': {
|
'tracker': {
|
||||||
@@ -296,6 +301,11 @@ class BTDeviceAggregate:
|
|||||||
'risk_factors': self.risk_factors,
|
'risk_factors': self.risk_factors,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# IRK
|
||||||
|
'has_irk': self.irk_hex is not None,
|
||||||
|
'irk_hex': self.irk_hex,
|
||||||
|
'irk_source_name': self.irk_source_name,
|
||||||
|
|
||||||
# Fingerprint
|
# Fingerprint
|
||||||
'fingerprint': {
|
'fingerprint': {
|
||||||
'id': self.payload_fingerprint_id,
|
'id': self.payload_fingerprint_id,
|
||||||
@@ -319,24 +329,46 @@ class BTDeviceAggregate:
|
|||||||
'rssi_current': self.rssi_current,
|
'rssi_current': self.rssi_current,
|
||||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
||||||
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||||
|
'rssi_min': self.rssi_min,
|
||||||
|
'rssi_max': self.rssi_max,
|
||||||
|
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
|
||||||
'range_band': self.range_band,
|
'range_band': self.range_band,
|
||||||
'proximity_band': self.proximity_band,
|
'proximity_band': self.proximity_band,
|
||||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||||
'distance_confidence': round(self.distance_confidence, 2),
|
'distance_confidence': round(self.distance_confidence, 2),
|
||||||
'is_randomized_mac': self.is_randomized_mac,
|
'is_randomized_mac': self.is_randomized_mac,
|
||||||
'last_seen': self.last_seen.isoformat(),
|
'last_seen': self.last_seen.isoformat(),
|
||||||
|
'first_seen': self.first_seen.isoformat(),
|
||||||
'age_seconds': self.age_seconds,
|
'age_seconds': self.age_seconds,
|
||||||
|
'duration_seconds': self.duration_seconds,
|
||||||
'seen_count': self.seen_count,
|
'seen_count': self.seen_count,
|
||||||
'heuristic_flags': self.heuristic_flags,
|
'seen_rate': round(self.seen_rate, 2),
|
||||||
'in_baseline': self.in_baseline,
|
'tx_power': self.tx_power,
|
||||||
'seen_before': self.seen_before,
|
'manufacturer_id': self.manufacturer_id,
|
||||||
# Tracker info for list view
|
'appearance': self.appearance,
|
||||||
'is_tracker': self.is_tracker,
|
'appearance_name': get_appearance_name(self.appearance),
|
||||||
|
'is_connectable': self.is_connectable,
|
||||||
|
'service_uuids': self.service_uuids,
|
||||||
|
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||||
|
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
|
||||||
|
'heuristic_flags': self.heuristic_flags,
|
||||||
|
'is_persistent': self.is_persistent,
|
||||||
|
'is_beacon_like': self.is_beacon_like,
|
||||||
|
'is_strong_stable': self.is_strong_stable,
|
||||||
|
'in_baseline': self.in_baseline,
|
||||||
|
'seen_before': self.seen_before,
|
||||||
|
# Tracker info for list view
|
||||||
|
'is_tracker': self.is_tracker,
|
||||||
'tracker_type': self.tracker_type,
|
'tracker_type': self.tracker_type,
|
||||||
'tracker_name': self.tracker_name,
|
'tracker_name': self.tracker_name,
|
||||||
'tracker_confidence': self.tracker_confidence,
|
'tracker_confidence': self.tracker_confidence,
|
||||||
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
||||||
|
'tracker_evidence': self.tracker_evidence,
|
||||||
'risk_score': round(self.risk_score, 2),
|
'risk_score': round(self.risk_score, 2),
|
||||||
|
'risk_factors': self.risk_factors,
|
||||||
|
'has_irk': self.irk_hex is not None,
|
||||||
|
'irk_hex': self.irk_hex,
|
||||||
|
'irk_source_name': self.irk_source_name,
|
||||||
'fingerprint_id': self.payload_fingerprint_id,
|
'fingerprint_id': self.payload_fingerprint_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-9
@@ -24,7 +24,9 @@ from .constants import (
|
|||||||
)
|
)
|
||||||
from .dbus_scanner import DBusScanner
|
from .dbus_scanner import DBusScanner
|
||||||
from .fallback_scanner import FallbackScanner
|
from .fallback_scanner import FallbackScanner
|
||||||
|
from .ubertooth_scanner import UbertoothScanner
|
||||||
from .heuristics import HeuristicsEngine
|
from .heuristics import HeuristicsEngine
|
||||||
|
from .irk_extractor import get_paired_irks
|
||||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -57,6 +59,7 @@ class BluetoothScanner:
|
|||||||
# Scanner backends
|
# Scanner backends
|
||||||
self._dbus_scanner: Optional[DBusScanner] = None
|
self._dbus_scanner: Optional[DBusScanner] = None
|
||||||
self._fallback_scanner: Optional[FallbackScanner] = None
|
self._fallback_scanner: Optional[FallbackScanner] = None
|
||||||
|
self._ubertooth_scanner: Optional[UbertoothScanner] = None
|
||||||
self._active_backend: Optional[str] = None
|
self._active_backend: Optional[str] = None
|
||||||
|
|
||||||
# Event queue for SSE streaming
|
# Event queue for SSE streaming
|
||||||
@@ -66,7 +69,7 @@ class BluetoothScanner:
|
|||||||
self._scan_timer: Optional[threading.Timer] = None
|
self._scan_timer: Optional[threading.Timer] = None
|
||||||
|
|
||||||
# Callbacks
|
# Callbacks
|
||||||
self._on_device_updated: Optional[Callable[[BTDeviceAggregate], None]] = None
|
self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = []
|
||||||
|
|
||||||
# Capability check result
|
# Capability check result
|
||||||
self._capabilities: Optional[SystemCapabilities] = None
|
self._capabilities: Optional[SystemCapabilities] = None
|
||||||
@@ -113,6 +116,8 @@ class BluetoothScanner:
|
|||||||
|
|
||||||
if mode == 'dbus':
|
if mode == 'dbus':
|
||||||
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||||
|
elif mode == 'ubertooth':
|
||||||
|
started, backend_used = self._start_ubertooth()
|
||||||
|
|
||||||
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
||||||
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
||||||
@@ -168,6 +173,18 @@ class BluetoothScanner:
|
|||||||
logger.warning(f"DBus scanner failed: {e}")
|
logger.warning(f"DBus scanner failed: {e}")
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Start Ubertooth One scanner."""
|
||||||
|
try:
|
||||||
|
self._ubertooth_scanner = UbertoothScanner(
|
||||||
|
on_observation=self._handle_observation,
|
||||||
|
)
|
||||||
|
if self._ubertooth_scanner.start():
|
||||||
|
return True, 'ubertooth'
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ubertooth scanner failed: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
||||||
"""Start fallback scanner."""
|
"""Start fallback scanner."""
|
||||||
try:
|
try:
|
||||||
@@ -204,6 +221,10 @@ class BluetoothScanner:
|
|||||||
self._fallback_scanner.stop()
|
self._fallback_scanner.stop()
|
||||||
self._fallback_scanner = None
|
self._fallback_scanner = None
|
||||||
|
|
||||||
|
if self._ubertooth_scanner:
|
||||||
|
self._ubertooth_scanner.stop()
|
||||||
|
self._ubertooth_scanner = None
|
||||||
|
|
||||||
# Update status
|
# Update status
|
||||||
self._status.is_scanning = False
|
self._status.is_scanning = False
|
||||||
self._active_backend = None
|
self._active_backend = None
|
||||||
@@ -216,6 +237,47 @@ class BluetoothScanner:
|
|||||||
|
|
||||||
logger.info("Bluetooth scan stopped")
|
logger.info("Bluetooth scan stopped")
|
||||||
|
|
||||||
|
def _match_irk(self, device: BTDeviceAggregate) -> None:
|
||||||
|
"""Check if a device address resolves against any paired IRK."""
|
||||||
|
if device.irk_hex is not None:
|
||||||
|
return # Already matched
|
||||||
|
|
||||||
|
address = device.address
|
||||||
|
if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only attempt RPA resolution on 6-byte addresses
|
||||||
|
addr_clean = address.replace(':', '').replace('-', '')
|
||||||
|
if len(addr_clean) != 12:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
paired = get_paired_irks()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not paired:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.bt_locate import resolve_rpa
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
for entry in paired:
|
||||||
|
irk_hex = entry.get('irk_hex', '')
|
||||||
|
if not irk_hex or len(irk_hex) != 32:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
irk = bytes.fromhex(irk_hex)
|
||||||
|
if resolve_rpa(irk, address):
|
||||||
|
device.irk_hex = irk_hex
|
||||||
|
device.irk_source_name = entry.get('name')
|
||||||
|
logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
def _handle_observation(self, observation: BTObservation) -> None:
|
def _handle_observation(self, observation: BTObservation) -> None:
|
||||||
"""Handle incoming observation from scanner backend."""
|
"""Handle incoming observation from scanner backend."""
|
||||||
try:
|
try:
|
||||||
@@ -225,20 +287,35 @@ class BluetoothScanner:
|
|||||||
# Evaluate heuristics
|
# Evaluate heuristics
|
||||||
self._heuristics.evaluate(device)
|
self._heuristics.evaluate(device)
|
||||||
|
|
||||||
|
# Check for IRK match
|
||||||
|
self._match_irk(device)
|
||||||
|
|
||||||
# Update device count
|
# Update device count
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._status.devices_found = self._aggregator.device_count
|
self._status.devices_found = self._aggregator.device_count
|
||||||
|
|
||||||
|
# Build summary with MAC cluster count
|
||||||
|
summary = device.to_summary_dict()
|
||||||
|
if device.payload_fingerprint_id:
|
||||||
|
summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count(
|
||||||
|
device.payload_fingerprint_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary['mac_cluster_count'] = 0
|
||||||
|
|
||||||
# Queue event
|
# Queue event
|
||||||
self._queue_event({
|
self._queue_event({
|
||||||
'type': 'device',
|
'type': 'device',
|
||||||
'action': 'update',
|
'action': 'update',
|
||||||
'device': device.to_summary_dict(),
|
'device': summary,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Callback
|
# Callbacks
|
||||||
if self._on_device_updated:
|
for cb in self._on_device_updated_callbacks:
|
||||||
self._on_device_updated(device)
|
try:
|
||||||
|
cb(device)
|
||||||
|
except Exception as cb_err:
|
||||||
|
logger.error(f"Device callback error: {cb_err}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling observation: {e}")
|
logger.error(f"Error handling observation: {e}")
|
||||||
@@ -368,13 +445,40 @@ class BluetoothScanner:
|
|||||||
return self._capabilities
|
return self._capabilities
|
||||||
|
|
||||||
def set_on_device_updated(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
def set_on_device_updated(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
||||||
"""Set callback for device updates."""
|
"""Set callback for device updates (legacy, adds to callback list)."""
|
||||||
self._on_device_updated = callback
|
self.add_device_callback(callback)
|
||||||
|
|
||||||
|
def add_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
||||||
|
"""Add a callback for device updates."""
|
||||||
|
if callback not in self._on_device_updated_callbacks:
|
||||||
|
self._on_device_updated_callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
||||||
|
"""Remove a device update callback."""
|
||||||
|
if callback in self._on_device_updated_callbacks:
|
||||||
|
self._on_device_updated_callbacks.remove(callback)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_scanning(self) -> bool:
|
def is_scanning(self) -> bool:
|
||||||
"""Check if scanning is active."""
|
"""Check if scanning is active.
|
||||||
return self._status.is_scanning
|
|
||||||
|
Cross-checks the backend scanner state, since bleak scans can
|
||||||
|
expire silently without calling stop_scan().
|
||||||
|
"""
|
||||||
|
if not self._status.is_scanning:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Detect backends that finished on their own (e.g. bleak timeout)
|
||||||
|
backend_alive = (
|
||||||
|
(self._dbus_scanner and self._dbus_scanner.is_scanning)
|
||||||
|
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
|
||||||
|
or (self._ubertooth_scanner and self._ubertooth_scanner.is_scanning)
|
||||||
|
)
|
||||||
|
if not backend_alive:
|
||||||
|
self._status.is_scanning = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_count(self) -> int:
|
def device_count(self) -> int:
|
||||||
|
|||||||
@@ -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
|
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
|
# DEAUTH ATTACK DETECTION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -531,6 +531,30 @@ def init_db() -> None:
|
|||||||
ON push_payloads(agent_id, received_at)
|
ON push_payloads(agent_id, received_at)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Tracked satellites table for persistent satellite management
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tracked_satellites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
norad_id TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
tle_line1 TEXT,
|
||||||
|
tle_line2 TEXT,
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
builtin BOOLEAN DEFAULT 0,
|
||||||
|
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Seed builtin satellites if not already present
|
||||||
|
conn.execute('''
|
||||||
|
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
|
||||||
|
VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1)
|
||||||
|
''')
|
||||||
|
conn.execute('''
|
||||||
|
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
|
||||||
|
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
|
||||||
|
''')
|
||||||
|
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
@@ -2170,3 +2194,111 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
|
|||||||
''', (f'-{max_age_hours} hours',))
|
''', (f'-{max_age_hours} hours',))
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tracked Satellites Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_tracked_satellites(enabled_only: bool = False) -> list[dict]:
|
||||||
|
"""Return all tracked satellites, optionally filtered to enabled only."""
|
||||||
|
with get_db() as conn:
|
||||||
|
if enabled_only:
|
||||||
|
rows = conn.execute(
|
||||||
|
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
|
||||||
|
'FROM tracked_satellites WHERE enabled = 1 ORDER BY builtin DESC, name'
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
|
||||||
|
'FROM tracked_satellites ORDER BY builtin DESC, name'
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'norad_id': r[0],
|
||||||
|
'name': r[1],
|
||||||
|
'tle_line1': r[2],
|
||||||
|
'tle_line2': r[3],
|
||||||
|
'enabled': bool(r[4]),
|
||||||
|
'builtin': bool(r[5]),
|
||||||
|
'added_at': r[6],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def add_tracked_satellite(
|
||||||
|
norad_id: str,
|
||||||
|
name: str,
|
||||||
|
tle_line1: str | None = None,
|
||||||
|
tle_line2: str | None = None,
|
||||||
|
enabled: bool = True,
|
||||||
|
builtin: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Insert a tracked satellite. Returns True if inserted, False if duplicate."""
|
||||||
|
with get_db() as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
'INSERT OR IGNORE INTO tracked_satellites '
|
||||||
|
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
(str(norad_id), name, tle_line1, tle_line2, int(enabled), int(builtin)),
|
||||||
|
)
|
||||||
|
return conn.total_changes > 0
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(f"Error adding tracked satellite {norad_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int:
|
||||||
|
"""Insert many tracked satellites at once. Returns count of newly inserted."""
|
||||||
|
added = 0
|
||||||
|
with get_db() as conn:
|
||||||
|
for sat in satellites_list:
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'INSERT OR IGNORE INTO tracked_satellites '
|
||||||
|
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
(
|
||||||
|
str(sat['norad_id']),
|
||||||
|
sat['name'],
|
||||||
|
sat.get('tle_line1'),
|
||||||
|
sat.get('tle_line2'),
|
||||||
|
int(sat.get('enabled', True)),
|
||||||
|
int(sat.get('builtin', False)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
added += 1
|
||||||
|
except (sqlite3.Error, KeyError) as e:
|
||||||
|
logger.warning(f"Error bulk-adding satellite: {e}")
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
|
||||||
|
"""Toggle enabled state for a tracked satellite."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?',
|
||||||
|
(int(enabled), str(norad_id)),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
|
||||||
|
"""Delete a tracked satellite by NORAD ID. Refuses to delete builtins."""
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT builtin FROM tracked_satellites WHERE norad_id = ?',
|
||||||
|
(str(norad_id),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False, 'Satellite not found'
|
||||||
|
if row[0]:
|
||||||
|
return False, 'Cannot remove builtin satellite'
|
||||||
|
conn.execute(
|
||||||
|
'DELETE FROM tracked_satellites WHERE norad_id = ?',
|
||||||
|
(str(norad_id),),
|
||||||
|
)
|
||||||
|
return True, 'Removed'
|
||||||
|
|
||||||
|
|||||||
@@ -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': {
|
'tscm': {
|
||||||
'name': 'TSCM Counter-Surveillance',
|
'name': 'TSCM Counter-Surveillance',
|
||||||
'tools': {
|
'tools': {
|
||||||
|
|||||||
+17
-2
@@ -264,6 +264,20 @@ class DSCDecoder:
|
|||||||
symbol_value = self._bits_to_symbol(symbol_bits)
|
symbol_value = self._bits_to_symbol(symbol_bits)
|
||||||
symbols.append(symbol_value)
|
symbols.append(symbol_value)
|
||||||
|
|
||||||
|
# Strip phasing sequence (RX/DX symbols 120-126) from the
|
||||||
|
# start of the message. Per ITU-R M.493, after the dot pattern
|
||||||
|
# there are 7 phasing symbols before the format specifier.
|
||||||
|
msg_start = 0
|
||||||
|
for i, sym in enumerate(symbols):
|
||||||
|
if 120 <= sym <= 126:
|
||||||
|
msg_start = i + 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
symbols = symbols[msg_start:]
|
||||||
|
|
||||||
|
if len(symbols) < 5:
|
||||||
|
return None
|
||||||
|
|
||||||
# Look for EOS (End of Sequence) - symbol 127
|
# Look for EOS (End of Sequence) - symbol 127
|
||||||
eos_found = False
|
eos_found = False
|
||||||
eos_index = -1
|
eos_index = -1
|
||||||
@@ -404,9 +418,10 @@ class DSCDecoder:
|
|||||||
digits.append(f'{sym:02d}')
|
digits.append(f'{sym:02d}')
|
||||||
|
|
||||||
mmsi = ''.join(digits)
|
mmsi = ''.join(digits)
|
||||||
# MMSI is 9 digits, might need to trim leading zero
|
# MMSI is 9 digits - trim the leading digit from the 10-digit
|
||||||
|
# BCD result since the first symbol's high digit is always 0
|
||||||
if len(mmsi) > 9:
|
if len(mmsi) > 9:
|
||||||
mmsi = mmsi[-9:]
|
mmsi = mmsi[1:]
|
||||||
|
|
||||||
return mmsi.zfill(9)
|
return mmsi.zfill(9)
|
||||||
|
|
||||||
|
|||||||
+361
-30
@@ -6,28 +6,86 @@ Provides GPS location data by connecting to the gpsd daemon.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import socket as _socket_mod
|
||||||
import threading
|
import threading
|
||||||
import time
|
from dataclasses import dataclass, field
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Callable
|
from typing import Callable
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.gps')
|
logger = logging.getLogger('intercept.gps')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GPSSatellite:
|
||||||
|
"""Individual satellite data from gpsd SKY message."""
|
||||||
|
prn: int
|
||||||
|
elevation: float | None = None # degrees
|
||||||
|
azimuth: float | None = None # degrees
|
||||||
|
snr: float | None = None # dB-Hz
|
||||||
|
used: bool = False
|
||||||
|
constellation: str = 'GPS' # GPS, GLONASS, Galileo, BeiDou, SBAS, QZSS
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'prn': self.prn,
|
||||||
|
'elevation': self.elevation,
|
||||||
|
'azimuth': self.azimuth,
|
||||||
|
'snr': self.snr,
|
||||||
|
'used': self.used,
|
||||||
|
'constellation': self.constellation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GPSSkyData:
|
||||||
|
"""Sky view data from gpsd SKY message."""
|
||||||
|
satellites: list[GPSSatellite] = field(default_factory=list)
|
||||||
|
hdop: float | None = None
|
||||||
|
vdop: float | None = None
|
||||||
|
pdop: float | None = None
|
||||||
|
tdop: float | None = None
|
||||||
|
gdop: float | None = None
|
||||||
|
xdop: float | None = None
|
||||||
|
ydop: float | None = None
|
||||||
|
nsat: int = 0 # total visible
|
||||||
|
usat: int = 0 # total used
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'satellites': [s.to_dict() for s in self.satellites],
|
||||||
|
'hdop': self.hdop,
|
||||||
|
'vdop': self.vdop,
|
||||||
|
'pdop': self.pdop,
|
||||||
|
'tdop': self.tdop,
|
||||||
|
'gdop': self.gdop,
|
||||||
|
'xdop': self.xdop,
|
||||||
|
'ydop': self.ydop,
|
||||||
|
'nsat': self.nsat,
|
||||||
|
'usat': self.usat,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GPSPosition:
|
class GPSPosition:
|
||||||
"""GPS position data."""
|
"""GPS position data."""
|
||||||
latitude: float
|
latitude: float
|
||||||
longitude: float
|
longitude: float
|
||||||
altitude: Optional[float] = None
|
altitude: float | None = None
|
||||||
speed: Optional[float] = None # m/s
|
speed: float | None = None # m/s
|
||||||
heading: Optional[float] = None # degrees
|
heading: float | None = None # degrees
|
||||||
satellites: Optional[int] = None
|
climb: float | None = None # m/s vertical speed
|
||||||
|
satellites: int | None = None
|
||||||
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||||
timestamp: Optional[datetime] = None
|
timestamp: datetime | None = None
|
||||||
device: Optional[str] = None
|
device: str | None = None
|
||||||
|
# Error estimates
|
||||||
|
epx: float | None = None # lon error (m)
|
||||||
|
epy: float | None = None # lat error (m)
|
||||||
|
epv: float | None = None # vertical error (m)
|
||||||
|
eps: float | None = None # speed error (m/s)
|
||||||
|
ept: float | None = None # time error (s)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary for JSON serialization."""
|
"""Convert to dictionary for JSON serialization."""
|
||||||
@@ -37,13 +95,45 @@ class GPSPosition:
|
|||||||
'altitude': self.altitude,
|
'altitude': self.altitude,
|
||||||
'speed': self.speed,
|
'speed': self.speed,
|
||||||
'heading': self.heading,
|
'heading': self.heading,
|
||||||
|
'climb': self.climb,
|
||||||
'satellites': self.satellites,
|
'satellites': self.satellites,
|
||||||
'fix_quality': self.fix_quality,
|
'fix_quality': self.fix_quality,
|
||||||
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||||
'device': self.device,
|
'device': self.device,
|
||||||
|
'epx': self.epx,
|
||||||
|
'epy': self.epy,
|
||||||
|
'epv': self.epv,
|
||||||
|
'eps': self.eps,
|
||||||
|
'ept': self.ept,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_constellation(prn: int, gnssid: int | None = None) -> str:
|
||||||
|
"""Classify satellite constellation from PRN or gnssid."""
|
||||||
|
if gnssid is not None:
|
||||||
|
mapping = {
|
||||||
|
0: 'GPS', 1: 'SBAS', 2: 'Galileo', 3: 'BeiDou',
|
||||||
|
4: 'IMES', 5: 'QZSS', 6: 'GLONASS', 7: 'NavIC',
|
||||||
|
}
|
||||||
|
return mapping.get(gnssid, 'GPS')
|
||||||
|
# Fall back to PRN range heuristic
|
||||||
|
if 1 <= prn <= 32:
|
||||||
|
return 'GPS'
|
||||||
|
elif 33 <= prn <= 64:
|
||||||
|
return 'SBAS'
|
||||||
|
elif 65 <= prn <= 96:
|
||||||
|
return 'GLONASS'
|
||||||
|
elif 120 <= prn <= 158:
|
||||||
|
return 'SBAS'
|
||||||
|
elif 201 <= prn <= 264:
|
||||||
|
return 'BeiDou'
|
||||||
|
elif 301 <= prn <= 336:
|
||||||
|
return 'Galileo'
|
||||||
|
elif 193 <= prn <= 200:
|
||||||
|
return 'QZSS'
|
||||||
|
return 'GPS'
|
||||||
|
|
||||||
|
|
||||||
class GPSDClient:
|
class GPSDClient:
|
||||||
"""
|
"""
|
||||||
Connects to gpsd daemon for GPS data.
|
Connects to gpsd daemon for GPS data.
|
||||||
@@ -58,35 +148,43 @@ class GPSDClient:
|
|||||||
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self._position: Optional[GPSPosition] = None
|
self._position: GPSPosition | None = None
|
||||||
|
self._sky: GPSSkyData | None = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._running = False
|
self._running = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: threading.Thread | None = None
|
||||||
self._socket: Optional['socket.socket'] = None
|
self._socket: _socket_mod.socket | None = None
|
||||||
self._last_update: Optional[datetime] = None
|
self._last_update: datetime | None = None
|
||||||
self._error: Optional[str] = None
|
self._error: str | None = None
|
||||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
||||||
self._device: Optional[str] = None
|
self._sky_callbacks: list[Callable[[GPSSkyData], None]] = []
|
||||||
|
self._device: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def position(self) -> Optional[GPSPosition]:
|
def position(self) -> GPSPosition | None:
|
||||||
"""Get the current GPS position."""
|
"""Get the current GPS position."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sky(self) -> GPSSkyData | None:
|
||||||
|
"""Get the current sky view data."""
|
||||||
|
with self._lock:
|
||||||
|
return self._sky
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if the client is running."""
|
"""Check if the client is running."""
|
||||||
return self._running
|
return self._running
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_update(self) -> Optional[datetime]:
|
def last_update(self) -> datetime | None:
|
||||||
"""Get the time of the last position update."""
|
"""Get the time of the last position update."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._last_update
|
return self._last_update
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error(self) -> Optional[str]:
|
def error(self) -> str | None:
|
||||||
"""Get any error message."""
|
"""Get any error message."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._error
|
return self._error
|
||||||
@@ -105,6 +203,15 @@ class GPSDClient:
|
|||||||
if callback in self._callbacks:
|
if callback in self._callbacks:
|
||||||
self._callbacks.remove(callback)
|
self._callbacks.remove(callback)
|
||||||
|
|
||||||
|
def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
|
||||||
|
"""Add a callback to be called on sky data updates."""
|
||||||
|
self._sky_callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
|
||||||
|
"""Remove a sky data update callback."""
|
||||||
|
if callback in self._sky_callbacks:
|
||||||
|
self._sky_callbacks.remove(callback)
|
||||||
|
|
||||||
def start(self) -> bool:
|
def start(self) -> bool:
|
||||||
"""Start receiving GPS data from gpsd."""
|
"""Start receiving GPS data from gpsd."""
|
||||||
import socket
|
import socket
|
||||||
@@ -135,10 +242,8 @@ class GPSDClient:
|
|||||||
self._error = str(e)
|
self._error = str(e)
|
||||||
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
|
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
|
||||||
if self._socket:
|
if self._socket:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
self._socket.close()
|
self._socket.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._socket = None
|
self._socket = None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -169,7 +274,7 @@ class GPSDClient:
|
|||||||
buffer = ""
|
buffer = ""
|
||||||
message_count = 0
|
message_count = 0
|
||||||
|
|
||||||
print(f"[GPS] gpsd read loop started", flush=True)
|
print("[GPS] gpsd read loop started", flush=True)
|
||||||
|
|
||||||
while self._running and self._socket:
|
while self._running and self._socket:
|
||||||
try:
|
try:
|
||||||
@@ -202,6 +307,8 @@ class GPSDClient:
|
|||||||
|
|
||||||
if msg_class == 'TPV':
|
if msg_class == 'TPV':
|
||||||
self._handle_tpv(msg)
|
self._handle_tpv(msg)
|
||||||
|
elif msg_class == 'SKY':
|
||||||
|
self._handle_sky(msg)
|
||||||
elif msg_class == 'DEVICES':
|
elif msg_class == 'DEVICES':
|
||||||
# Track connected device
|
# Track connected device
|
||||||
devices = msg.get('devices', [])
|
devices = msg.get('devices', [])
|
||||||
@@ -239,11 +346,9 @@ class GPSDClient:
|
|||||||
timestamp = None
|
timestamp = None
|
||||||
time_str = msg.get('time')
|
time_str = msg.get('time')
|
||||||
if time_str:
|
if time_str:
|
||||||
try:
|
with contextlib.suppress(ValueError, AttributeError):
|
||||||
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
|
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
|
||||||
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
position = GPSPosition(
|
position = GPSPosition(
|
||||||
latitude=lat,
|
latitude=lat,
|
||||||
@@ -251,14 +356,58 @@ class GPSDClient:
|
|||||||
altitude=msg.get('alt'),
|
altitude=msg.get('alt'),
|
||||||
speed=msg.get('speed'), # m/s in gpsd
|
speed=msg.get('speed'), # m/s in gpsd
|
||||||
heading=msg.get('track'),
|
heading=msg.get('track'),
|
||||||
|
climb=msg.get('climb'),
|
||||||
fix_quality=mode,
|
fix_quality=mode,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
device=self._device or f"gpsd://{self.host}:{self.port}",
|
device=self._device or f"gpsd://{self.host}:{self.port}",
|
||||||
|
epx=msg.get('epx'),
|
||||||
|
epy=msg.get('epy'),
|
||||||
|
epv=msg.get('epv'),
|
||||||
|
eps=msg.get('eps'),
|
||||||
|
ept=msg.get('ept'),
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
|
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
|
||||||
self._update_position(position)
|
self._update_position(position)
|
||||||
|
|
||||||
|
def _handle_sky(self, msg: dict) -> None:
|
||||||
|
"""Handle SKY (satellite sky view) message from gpsd."""
|
||||||
|
sats = []
|
||||||
|
for sat in msg.get('satellites', []):
|
||||||
|
prn = sat.get('PRN', 0)
|
||||||
|
gnssid = sat.get('gnssid')
|
||||||
|
sats.append(GPSSatellite(
|
||||||
|
prn=prn,
|
||||||
|
elevation=sat.get('el'),
|
||||||
|
azimuth=sat.get('az'),
|
||||||
|
snr=sat.get('ss'),
|
||||||
|
used=sat.get('used', False),
|
||||||
|
constellation=_classify_constellation(prn, gnssid),
|
||||||
|
))
|
||||||
|
|
||||||
|
sky_data = GPSSkyData(
|
||||||
|
satellites=sats,
|
||||||
|
hdop=msg.get('hdop'),
|
||||||
|
vdop=msg.get('vdop'),
|
||||||
|
pdop=msg.get('pdop'),
|
||||||
|
tdop=msg.get('tdop'),
|
||||||
|
gdop=msg.get('gdop'),
|
||||||
|
xdop=msg.get('xdop'),
|
||||||
|
ydop=msg.get('ydop'),
|
||||||
|
nsat=len(sats),
|
||||||
|
usat=sum(1 for s in sats if s.used),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._sky = sky_data
|
||||||
|
|
||||||
|
# Notify sky callbacks
|
||||||
|
for callback in self._sky_callbacks:
|
||||||
|
try:
|
||||||
|
callback(sky_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GPS sky callback error: {e}")
|
||||||
|
|
||||||
def _update_position(self, position: GPSPosition) -> None:
|
def _update_position(self, position: GPSPosition) -> None:
|
||||||
"""Update the current position and notify callbacks."""
|
"""Update the current position and notify callbacks."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -275,18 +424,19 @@ class GPSDClient:
|
|||||||
|
|
||||||
|
|
||||||
# Global GPS client instance
|
# Global GPS client instance
|
||||||
_gps_client: Optional[GPSDClient] = None
|
_gps_client: GPSDClient | None = None
|
||||||
_gps_lock = threading.Lock()
|
_gps_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_gps_reader() -> Optional[GPSDClient]:
|
def get_gps_reader() -> GPSDClient | None:
|
||||||
"""Get the global GPS client instance."""
|
"""Get the global GPS client instance."""
|
||||||
with _gps_lock:
|
with _gps_lock:
|
||||||
return _gps_client
|
return _gps_client
|
||||||
|
|
||||||
|
|
||||||
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
callback: Callable[[GPSPosition], None] | None = None,
|
||||||
|
sky_callback: Callable[[GPSSkyData], None] | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Start the global GPS client connected to gpsd.
|
Start the global GPS client connected to gpsd.
|
||||||
|
|
||||||
@@ -294,6 +444,7 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
|||||||
host: gpsd host (default localhost)
|
host: gpsd host (default localhost)
|
||||||
port: gpsd port (default 2947)
|
port: gpsd port (default 2947)
|
||||||
callback: Optional callback for position updates
|
callback: Optional callback for position updates
|
||||||
|
sky_callback: Optional callback for sky data updates
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if started successfully
|
True if started successfully
|
||||||
@@ -307,9 +458,11 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
|||||||
|
|
||||||
_gps_client = GPSDClient(host, port)
|
_gps_client = GPSDClient(host, port)
|
||||||
|
|
||||||
# Register callback BEFORE starting to avoid race condition
|
# Register callbacks BEFORE starting to avoid race condition
|
||||||
if callback:
|
if callback:
|
||||||
_gps_client.add_callback(callback)
|
_gps_client.add_callback(callback)
|
||||||
|
if sky_callback:
|
||||||
|
_gps_client.add_sky_callback(sky_callback)
|
||||||
|
|
||||||
return _gps_client.start()
|
return _gps_client.start()
|
||||||
|
|
||||||
@@ -324,9 +477,187 @@ def stop_gps() -> None:
|
|||||||
_gps_client = None
|
_gps_client = None
|
||||||
|
|
||||||
|
|
||||||
def get_current_position() -> Optional[GPSPosition]:
|
def get_current_position() -> GPSPosition | None:
|
||||||
"""Get the current GPS position from the global client."""
|
"""Get the current GPS position from the global client."""
|
||||||
client = get_gps_reader()
|
client = get_gps_reader()
|
||||||
if client:
|
if client:
|
||||||
return client.position
|
return client.position
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# GPS device detection and gpsd auto-start
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
_gpsd_process: 'subprocess.Popen | None' = None
|
||||||
|
_gpsd_process_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_gps_devices() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Detect connected GPS serial devices.
|
||||||
|
|
||||||
|
Returns list of dicts with 'path' and 'description' keys.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
devices: list[dict] = []
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == 'Linux':
|
||||||
|
# Common USB GPS device paths
|
||||||
|
patterns = ['/dev/ttyUSB*', '/dev/ttyACM*']
|
||||||
|
for pattern in patterns:
|
||||||
|
for path in sorted(glob.glob(pattern)):
|
||||||
|
desc = _describe_device_linux(path)
|
||||||
|
devices.append({'path': path, 'description': desc})
|
||||||
|
|
||||||
|
# Also check /dev/serial/by-id for descriptive names
|
||||||
|
serial_dir = '/dev/serial/by-id'
|
||||||
|
if os.path.isdir(serial_dir):
|
||||||
|
for name in sorted(os.listdir(serial_dir)):
|
||||||
|
full = os.path.join(serial_dir, name)
|
||||||
|
real = os.path.realpath(full)
|
||||||
|
# Skip if we already found this device
|
||||||
|
if any(d['path'] == real for d in devices):
|
||||||
|
# Update description with the more descriptive name
|
||||||
|
for d in devices:
|
||||||
|
if d['path'] == real:
|
||||||
|
d['description'] = name
|
||||||
|
continue
|
||||||
|
devices.append({'path': real, 'description': name})
|
||||||
|
|
||||||
|
elif system == 'Darwin':
|
||||||
|
# macOS: USB serial devices (prefer cu. over tty. for outgoing)
|
||||||
|
patterns = ['/dev/cu.usbmodem*', '/dev/cu.usbserial*']
|
||||||
|
for pattern in patterns:
|
||||||
|
for path in sorted(glob.glob(pattern)):
|
||||||
|
desc = _describe_device_macos(path)
|
||||||
|
devices.append({'path': path, 'description': desc})
|
||||||
|
|
||||||
|
# Sort: devices with GPS-related descriptions first
|
||||||
|
gps_keywords = ('gps', 'gnss', 'u-blox', 'ublox', 'nmea', 'sirf', 'navigation')
|
||||||
|
devices.sort(key=lambda d: (
|
||||||
|
0 if any(k in d['description'].lower() for k in gps_keywords) else 1
|
||||||
|
))
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_device_linux(path: str) -> str:
|
||||||
|
"""Get a human-readable description of a Linux serial device."""
|
||||||
|
import os
|
||||||
|
basename = os.path.basename(path)
|
||||||
|
# Try to read from sysfs
|
||||||
|
try:
|
||||||
|
# /sys/class/tty/ttyUSB0/device/../product
|
||||||
|
sysfs = f'/sys/class/tty/{basename}/device/../product'
|
||||||
|
if os.path.exists(sysfs):
|
||||||
|
with open(sysfs) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return basename
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_device_macos(path: str) -> str:
|
||||||
|
"""Get a description of a macOS serial device."""
|
||||||
|
import os
|
||||||
|
return os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
|
def is_gpsd_running(host: str = 'localhost', port: int = 2947) -> bool:
|
||||||
|
"""Check if gpsd is reachable."""
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(1.0)
|
||||||
|
sock.connect((host, port))
|
||||||
|
sock.close()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def start_gpsd_daemon(device_path: str, host: str = 'localhost',
|
||||||
|
port: int = 2947) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Start gpsd daemon pointing at the given device.
|
||||||
|
|
||||||
|
Returns (success, message) tuple.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
global _gpsd_process
|
||||||
|
|
||||||
|
with _gpsd_process_lock:
|
||||||
|
# Already running?
|
||||||
|
if is_gpsd_running(host, port):
|
||||||
|
return True, 'gpsd already running'
|
||||||
|
|
||||||
|
gpsd_bin = shutil.which('gpsd')
|
||||||
|
if not gpsd_bin:
|
||||||
|
return False, 'gpsd not installed'
|
||||||
|
|
||||||
|
# Stop any existing managed process
|
||||||
|
stop_gpsd_daemon()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
if not os.path.exists(device_path):
|
||||||
|
return False, f'Device {device_path} not found'
|
||||||
|
|
||||||
|
cmd = [gpsd_bin, '-N', '-n', '-S', str(port), device_path]
|
||||||
|
logger.info(f"Starting gpsd: {' '.join(cmd)}")
|
||||||
|
print(f"[GPS] Starting gpsd: {' '.join(cmd)}", flush=True)
|
||||||
|
|
||||||
|
_gpsd_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Give gpsd a moment to start
|
||||||
|
import time
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
if _gpsd_process.poll() is not None:
|
||||||
|
stderr = ''
|
||||||
|
if _gpsd_process.stderr:
|
||||||
|
stderr = _gpsd_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
|
msg = f'gpsd exited with code {_gpsd_process.returncode}'
|
||||||
|
if stderr:
|
||||||
|
msg += f': {stderr}'
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
# Verify it's listening
|
||||||
|
if is_gpsd_running(host, port):
|
||||||
|
return True, f'gpsd started on {device_path}'
|
||||||
|
else:
|
||||||
|
return False, 'gpsd started but not accepting connections'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start gpsd: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_gpsd_daemon() -> None:
|
||||||
|
"""Stop the managed gpsd daemon process."""
|
||||||
|
global _gpsd_process
|
||||||
|
|
||||||
|
with _gpsd_process_lock:
|
||||||
|
if _gpsd_process and _gpsd_process.poll() is None:
|
||||||
|
try:
|
||||||
|
_gpsd_process.terminate()
|
||||||
|
_gpsd_process.wait(timeout=3.0)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_gpsd_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("Stopped gpsd daemon")
|
||||||
|
print("[GPS] Stopped gpsd daemon", flush=True)
|
||||||
|
_gpsd_process = None
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from .dependencies import check_tool
|
from .dependencies import check_tool
|
||||||
@@ -117,6 +120,93 @@ def cleanup_stale_processes() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid'
|
||||||
|
|
||||||
|
|
||||||
|
def write_dump1090_pid(pid: int) -> None:
|
||||||
|
"""Write the PID of an app-spawned dump1090 process to a PID file."""
|
||||||
|
try:
|
||||||
|
_DUMP1090_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_DUMP1090_PID_FILE.write_text(str(pid))
|
||||||
|
logger.debug(f"Wrote dump1090 PID file: {pid}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Failed to write dump1090 PID file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_dump1090_pid() -> None:
|
||||||
|
"""Remove the dump1090 PID file."""
|
||||||
|
try:
|
||||||
|
_DUMP1090_PID_FILE.unlink(missing_ok=True)
|
||||||
|
logger.debug("Cleared dump1090 PID file")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Failed to clear dump1090 PID file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dump1090_process(pid: int) -> bool:
|
||||||
|
"""Check if the given PID is actually a dump1090/readsb process."""
|
||||||
|
try:
|
||||||
|
if platform.system() == 'Linux':
|
||||||
|
cmdline_path = Path(f'/proc/{pid}/cmdline')
|
||||||
|
if cmdline_path.exists():
|
||||||
|
cmdline = cmdline_path.read_bytes().replace(b'\x00', b' ').decode('utf-8', errors='ignore')
|
||||||
|
return 'dump1090' in cmdline or 'readsb' in cmdline
|
||||||
|
# macOS or fallback
|
||||||
|
result = subprocess.run(
|
||||||
|
['ps', '-p', str(pid), '-o', 'comm='],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
comm = result.stdout.strip()
|
||||||
|
return 'dump1090' in comm or 'readsb' in comm
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_stale_dump1090() -> None:
|
||||||
|
"""Kill a stale app-spawned dump1090 using the PID file.
|
||||||
|
|
||||||
|
Safe no-op if no PID file exists, process is dead, or PID was reused
|
||||||
|
by another program.
|
||||||
|
"""
|
||||||
|
if not _DUMP1090_PID_FILE.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(_DUMP1090_PID_FILE.read_text().strip())
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
logger.warning(f"Invalid dump1090 PID file: {e}")
|
||||||
|
clear_dump1090_pid()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify this PID is still a dump1090/readsb process
|
||||||
|
if not _is_dump1090_process(pid):
|
||||||
|
logger.debug(f"PID {pid} is not dump1090/readsb (dead or reused), removing stale PID file")
|
||||||
|
clear_dump1090_pid()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Kill the process group
|
||||||
|
logger.info(f"Killing stale app-spawned dump1090 (PID {pid})")
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(pid)
|
||||||
|
os.killpg(pgid, signal.SIGTERM)
|
||||||
|
# Brief wait for graceful shutdown
|
||||||
|
for _ in range(10):
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0) # Check if still alive
|
||||||
|
time.sleep(0.2)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Still alive, force kill
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug(f"Error killing stale dump1090 PID {pid}: {e}")
|
||||||
|
|
||||||
|
clear_dump1090_pid()
|
||||||
|
|
||||||
|
|
||||||
def is_valid_mac(mac: str | None) -> bool:
|
def is_valid_mac(mac: str | None) -> bool:
|
||||||
"""Validate MAC address format."""
|
"""Validate MAC address format."""
|
||||||
if not mac:
|
if not mac:
|
||||||
|
|||||||
+51
-20
@@ -6,15 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .base import SDRCapabilities, SDRDevice, SDRType
|
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:
|
def _check_tool(name: str) -> bool:
|
||||||
@@ -295,16 +311,29 @@ def _add_soapy_device(
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def detect_hackrf_devices() -> list[SDRDevice]:
|
def detect_hackrf_devices() -> list[SDRDevice]:
|
||||||
"""
|
"""
|
||||||
Detect HackRF devices using native hackrf_info tool.
|
Detect HackRF devices using native hackrf_info tool.
|
||||||
|
|
||||||
Fallback for when SoapySDR is not available.
|
Fallback for when SoapySDR is not available.
|
||||||
"""
|
"""
|
||||||
devices: list[SDRDevice] = []
|
global _hackrf_cache, _hackrf_cache_ts
|
||||||
|
now = time.time()
|
||||||
if not _check_tool('hackrf_info'):
|
|
||||||
return devices
|
# 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:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -342,10 +371,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||||
))
|
))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"HackRF detection error: {e}")
|
logger.debug(f"HackRF detection error: {e}")
|
||||||
|
|
||||||
return devices
|
_hackrf_cache = list(devices)
|
||||||
|
_hackrf_cache_ts = now
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def probe_rtlsdr_device(device_index: int) -> str | None:
|
def probe_rtlsdr_device(device_index: int) -> str | None:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user