mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 00:03:33 -07:00
Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 243a0f0e7f | |||
| 7c3ec9e920 | |||
| 4639146f05 | |||
| a354fee792 | |||
| a1cb6b2692 | |||
| 8376415074 | |||
| b25615317b | |||
| 311d268b10 | |||
| 6581620cb0 | |||
| aa963519e9 | |||
| 4a6dddbb48 | |||
| f217230ef4 | |||
| e27b4d78cb | |||
| d41ba61aee | |||
| 35cf01c11e | |||
| 00c9a6fdd9 | |||
| fce66a6a60 | |||
| b023e4cdc7 | |||
| a8f2912b90 | |||
| a2a7ac8fec | |||
| 4e168ff502 | |||
| 51aba87852 | |||
| 4c13e98091 | |||
| 54c849ab60 | |||
| 94ee22fdd4 | |||
| b96eb8ccba | |||
| b8a80460bf | |||
| 7130c2d4c4 | |||
| 62c34c1e95 | |||
| e413f54651 | |||
| 1a4af214bf | |||
| c2891938ab | |||
| 2bed35dd64 | |||
| 0c656cff2b | |||
| e03ba3f5ed | |||
| c6ff8abf11 | |||
| eff6ca3e87 | |||
| 1a5b076a8d | |||
| 90e88fc469 | |||
| 98f6d18bea | |||
| 7d69cac7e7 | |||
| c6a8a4a492 | |||
| ca15e227cd | |||
| 391aff52ce | |||
| 3dc16b392b | |||
| 4d7be047da | |||
| 182e1f3239 | |||
| 87782319f2 | |||
| 6b7f817aa6 | |||
| 82f442ffb8 | |||
| 1924203c19 | |||
| f18ed26005 | |||
| 897cea5b54 | |||
| cd2d51ee40 | |||
| 39ed4bffba | |||
| 6010c7d589 | |||
| 01978730ba | |||
| 451eff83a8 | |||
| 7cb2efca30 | |||
| 33953fcf2b | |||
| 1eec4a2342 | |||
| 2dc4940ca2 | |||
| cd5f1464b6 | |||
| 4aeb51a973 | |||
| 15efe56762 | |||
| 995bc17418 | |||
| c3dcf1401a | |||
| 6f9873d47f | |||
| 28185727e3 | |||
| 48795f6ec3 | |||
| f5021a0fdf | |||
| 7312f330ed | |||
| 2115bc551d | |||
| f6c19af33a | |||
| ebd9eb81f2 | |||
| c87c01cdfe | |||
| 19a94d4a84 | |||
| cca04918a9 | |||
| 777b83f6e0 | |||
| 455bc05c69 | |||
| 37842dc1ef | |||
| 01f3cc845b | |||
| bdba56bef1 | |||
| a5ea632cc2 | |||
| a3b81bead8 | |||
| 026337a350 | |||
| 44b1a74838 | |||
| 7aae2944d4 | |||
| 766a51753d | |||
| 92e5e7c6da | |||
| 154dc898ff | |||
| beb38b6b98 | |||
| f04ba7f143 | |||
| fd0953bfb5 | |||
| b312eb20aa | |||
| 8eb8a2fe97 | |||
| 13be4302c3 | |||
| 5fd45d3e94 | |||
| e88b815dc9 | |||
| 556a4ffcc2 | |||
| 03c5d33eb7 | |||
| f9786aa75a | |||
| b87623cf66 | |||
| 4d24e648ab | |||
| 99f42f66b2 | |||
| 3240b0788b | |||
| 3ab1501a90 | |||
| 7e42e00449 | |||
| 51ea558e19 | |||
| 75bd3228e5 | |||
| 86e4ba7e29 | |||
| 4bbc00b765 | |||
| 32b373bf2c | |||
| cdfc10c854 | |||
| adb472956e | |||
| 60d3cff5e7 | |||
| b208576068 | |||
| 1ee64efc81 | |||
| bb4ccc6355 | |||
| 70e9611f02 | |||
| d05144bdb3 | |||
| bfd92e3883 | |||
| 3b191dccd6 | |||
| c0eda84644 | |||
| 19f382a31a | |||
| 3b205db329 | |||
| d8c5491200 | |||
| b9c8b1c730 | |||
| 684f17f507 | |||
| a0f64f6fa6 | |||
| 06c218c736 | |||
| 1e249a0eec | |||
| 249fccadd3 | |||
| 82957ab162 | |||
| e8727358eb | |||
| 28891f4709 | |||
| 297f971bd5 | |||
| 4bf35cf786 | |||
| 28e19b8898 | |||
| 4ed7969e90 | |||
| ef7d8cca9f | |||
| 1683d98b90 | |||
| 18aa7fe669 | |||
| 8409a4469d | |||
| b75492ec18 | |||
| fef8db6c00 | |||
| a70502fb77 | |||
| e8a9afa221 | |||
| 8e9588c4ff | |||
| 7bc1d5b643 | |||
| ef14f5f1a1 | |||
| 7caa7247ef | |||
| 04d9d2fd56 | |||
| ff36687f53 | |||
| b860a4309b | |||
| f409222f8a | |||
| 1c051933b7 | |||
| c83a2ef56f | |||
| 6d1f8f022e | |||
| 500ddf59fe | |||
| 5e4be0c279 | |||
| 7b68c19dc5 | |||
| 780ba9c58b |
@@ -35,6 +35,7 @@ htmlcov/
|
|||||||
|
|
||||||
# Local Postgres data
|
# Local Postgres data
|
||||||
pgdata/
|
pgdata/
|
||||||
|
pgdata.bak/
|
||||||
|
|
||||||
# Captured files (don't include in image)
|
# Captured files (don't include in image)
|
||||||
*.cap
|
*.cap
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Uncomment and set to use external storage for ADS-B history
|
||||||
|
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||||
+11
@@ -54,3 +54,14 @@ intercept_agent_*.cfg
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
/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
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|||||||
@@ -2,6 +2,39 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.15.0] - 2026-02-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
||||||
|
- Click-to-tune, zoom controls, and auto-scaling quantization
|
||||||
|
- Shared waterfall UI across SDR modes with function bar controls
|
||||||
|
- WebSocket frame serialization and connection reuse
|
||||||
|
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
|
||||||
|
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
|
||||||
|
- Real-time decode progress with partial image streaming
|
||||||
|
- VIS detector state in signal monitor diagnostics
|
||||||
|
- Image gallery with delete and download functionality
|
||||||
|
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||||
|
- **SSTV Image Gallery** - Delete and download decoded images
|
||||||
|
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- DMR dsd-fme protocol flags, device label, and tuning controls
|
||||||
|
- DMR frontend/backend state desync causing 409 on start
|
||||||
|
- Digital voice decoder producing no output due to wrong dsd-fme flags
|
||||||
|
- SDR device lock-up from unreleased device registry on process crash
|
||||||
|
- APRS crash on large station count and station list overflow
|
||||||
|
- Settings modal overflowing viewport on smaller screens
|
||||||
|
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
|
||||||
|
- PD120 SSTV decode hang and false leader tone detection
|
||||||
|
- WebSocket waterfall blocked by login redirect
|
||||||
|
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- GSM Spy functionality removed for legal compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.14.0] - 2026-02-06
|
## [2.14.0] - 2026-02-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+74
-1
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||||
|
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||||
|
|
||||||
# Install system dependencies for SDR tools
|
# Install system dependencies for SDR tools
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
@@ -21,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 \
|
||||||
@@ -41,6 +53,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
soapysdr-module-rtlsdr \
|
soapysdr-module-rtlsdr \
|
||||||
soapysdr-module-hackrf \
|
soapysdr-module-hackrf \
|
||||||
soapysdr-module-lms7 \
|
soapysdr-module-lms7 \
|
||||||
|
soapysdr-module-airspy \
|
||||||
|
airspy \
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
hackrf \
|
||||||
# Utilities
|
# Utilities
|
||||||
@@ -56,9 +70,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 \
|
||||||
@@ -113,6 +140,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 \
|
||||||
@@ -135,6 +199,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 \
|
||||||
@@ -142,6 +207,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 \
|
||||||
@@ -164,7 +237,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
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ Support the developer of this open-source project
|
|||||||
- **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
|
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Listening Post** - Frequency scanner with audio monitoring
|
||||||
|
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump
|
||||||
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
|
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
|
||||||
- **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
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **Satellite Tracking** - Pass prediction using TLE data
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
@@ -60,15 +61,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)
|
||||||
|
|
||||||
@@ -200,6 +240,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[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/)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ 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
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
@@ -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)
|
||||||
@@ -244,6 +248,10 @@ sdr_device_registry_lock = threading.Lock()
|
|||||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||||
"""Claim an SDR device for a mode.
|
"""Claim an SDR device for a mode.
|
||||||
|
|
||||||
|
Checks the in-app registry first, then probes the USB device to
|
||||||
|
catch stale handles held by external processes (e.g. a leftover
|
||||||
|
rtl_fm from a previous crash).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to claim
|
device_index: The SDR device index to claim
|
||||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
@@ -255,6 +263,16 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
|||||||
if device_index in sdr_device_registry:
|
if device_index in sdr_device_registry:
|
||||||
in_use_by = sdr_device_registry[device_index]
|
in_use_by = sdr_device_registry[device_index]
|
||||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||||
|
|
||||||
|
# Probe the USB device to catch external processes holding the handle
|
||||||
|
try:
|
||||||
|
from utils.sdr.detection import probe_rtlsdr_device
|
||||||
|
usb_error = probe_rtlsdr_device(device_index)
|
||||||
|
if usb_error:
|
||||||
|
return usb_error
|
||||||
|
except Exception:
|
||||||
|
pass # If probe fails, let the caller proceed normally
|
||||||
|
|
||||||
sdr_device_registry[device_index] = mode_name
|
sdr_device_registry[device_index] = mode_name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -292,6 +310,10 @@ def require_login():
|
|||||||
if request.path.startswith('/listening/audio/'):
|
if request.path.startswith('/listening/audio/'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Allow WebSocket upgrade requests (page load already required auth)
|
||||||
|
if request.path.startswith('/ws/'):
|
||||||
|
return None
|
||||||
|
|
||||||
# Controller API endpoints use API key auth, not session auth
|
# Controller API endpoints use API key auth, not session auth
|
||||||
# Allow agent push/pull endpoints without session login
|
# Allow agent push/pull endpoints without session login
|
||||||
if request.path.startswith('/controller/'):
|
if request.path.startswith('/controller/'):
|
||||||
@@ -352,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -623,8 +647,27 @@ def export_bluetooth() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
def _get_subghz_active() -> bool:
|
||||||
def health_check() -> Response:
|
"""Check if SubGHz manager has an active process."""
|
||||||
|
try:
|
||||||
|
from utils.subghz import get_subghz_manager
|
||||||
|
return get_subghz_manager().active_mode != 'idle'
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dmr_active() -> bool:
|
||||||
|
"""Check if Digital Voice decoder has an active process."""
|
||||||
|
try:
|
||||||
|
from routes import dmr as dmr_module
|
||||||
|
proc = dmr_module.dmr_dsd_process
|
||||||
|
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
import time
|
import time
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -638,11 +681,12 @@ def health_check() -> Response:
|
|||||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
'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),
|
||||||
'vessel_count': len(ais_vessels),
|
'vessel_count': len(ais_vessels),
|
||||||
@@ -671,7 +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',
|
||||||
|
'hackrf_transfer', 'hackrf_sweep'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -736,7 +782,14 @@ def kill_all() -> Response:
|
|||||||
# Reset Bluetooth v2 scanner
|
# Reset Bluetooth v2 scanner
|
||||||
try:
|
try:
|
||||||
reset_bluetooth_scanner()
|
reset_bluetooth_scanner()
|
||||||
killed.append('bluetooth_scanner')
|
killed.append('bluetooth')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reset SubGHz state
|
||||||
|
try:
|
||||||
|
from utils.subghz import get_subghz_manager
|
||||||
|
get_subghz_manager().stop_all()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -829,6 +882,18 @@ def main() -> None:
|
|||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
# Register database cleanup functions
|
||||||
|
from utils.database import (
|
||||||
|
cleanup_old_signal_history,
|
||||||
|
cleanup_old_timeline_entries,
|
||||||
|
cleanup_old_dsc_alerts,
|
||||||
|
cleanup_old_payloads
|
||||||
|
)
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||||
|
|
||||||
# Start automatic cleanup of stale data entries
|
# Start automatic cleanup of stale data entries
|
||||||
cleanup_manager.start()
|
cleanup_manager.start()
|
||||||
|
|
||||||
@@ -836,6 +901,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:
|
||||||
@@ -868,6 +942,14 @@ def main() -> None:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||||
|
|
||||||
|
# Initialize WebSocket for waterfall streaming
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import init_waterfall_websocket
|
||||||
|
init_waterfall_websocket(app)
|
||||||
|
print("WebSocket waterfall streaming enabled")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"WebSocket waterfall disabled: {e}")
|
||||||
|
|
||||||
print(f"Open http://localhost:{args.port} in your browser")
|
print(f"Open http://localhost:{args.port} in your browser")
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
|
|||||||
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,36 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.14.0"
|
VERSION = "2.16.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.16.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
|
||||||
|
"Weather satellite auto-scheduler with polar plot and ground track map",
|
||||||
|
"SatDump support for local (non-Docker) installs via setup.sh",
|
||||||
|
"DMR audio output, frequency persistence, and bookmarks",
|
||||||
|
"Shared waterfall UI across SDR modes",
|
||||||
|
"Listening post audio stuttering fix and SDR race condition fixes",
|
||||||
|
"Multi-arch Docker build support (amd64 + arm64)",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.15.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
|
||||||
|
"Cross-module frequency routing from Listening Post to decoders",
|
||||||
|
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||||
|
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||||
|
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||||
|
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
||||||
|
"SDR device lock-up fix from unreleased device registry on crash",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.14.0",
|
"version": "2.14.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
@@ -198,21 +224,47 @@ 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)
|
||||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||||
|
|
||||||
|
# Alerting
|
||||||
|
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
||||||
|
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
||||||
|
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||||
|
|
||||||
# Admin credentials
|
# Admin credentials
|
||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure application logging."""
|
"""Configure application logging."""
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
+30
-11
@@ -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"]
|
||||||
@@ -94,7 +112,8 @@ services:
|
|||||||
- POSTGRES_USER=intercept
|
- POSTGRES_USER=intercept
|
||||||
- POSTGRES_PASSWORD=intercept
|
- POSTGRES_PASSWORD=intercept
|
||||||
volumes:
|
volumes:
|
||||||
- ./pgdata:/var/lib/postgresql/data
|
# Default local path (override with PGDATA_PATH for external storage)
|
||||||
|
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||||
|
|||||||
+78
-76
@@ -61,88 +61,90 @@ INTERCEPT automatically detects known trackers:
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
DMSP 5D-3 F16 (USA 172)
|
||||||
|
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
|
||||||
|
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
|
||||||
|
METEOSAT-9 (MSG-2)
|
||||||
|
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
|
||||||
|
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
|
||||||
|
DMSP 5D-3 F17 (USA 191)
|
||||||
|
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
|
||||||
|
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
|
||||||
|
FENGYUN 3A
|
||||||
|
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
|
||||||
|
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
|
||||||
|
GOES 14
|
||||||
|
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
|
||||||
|
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
|
||||||
|
DMSP 5D-3 F18 (USA 210)
|
||||||
|
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
|
||||||
|
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
|
||||||
|
EWS-G2 (GOES 15)
|
||||||
|
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
|
||||||
|
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
|
||||||
|
COMS 1
|
||||||
|
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
|
||||||
|
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
|
||||||
|
FENGYUN 3B
|
||||||
|
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
|
||||||
|
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
|
||||||
|
SUOMI NPP
|
||||||
|
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
|
||||||
|
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
|
||||||
|
METEOSAT-10 (MSG-3)
|
||||||
|
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
|
||||||
|
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
|
||||||
|
METOP-B
|
||||||
|
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
|
||||||
|
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
|
||||||
|
INSAT-3D
|
||||||
|
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
|
||||||
|
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
|
||||||
|
FENGYUN 3C
|
||||||
|
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
|
||||||
|
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
|
||||||
|
METEOR-M 2
|
||||||
|
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
|
||||||
|
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
|
||||||
|
HIMAWARI-8
|
||||||
|
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
|
||||||
|
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
|
||||||
|
FENGYUN 2G
|
||||||
|
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
|
||||||
|
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
|
||||||
|
METEOSAT-11 (MSG-4)
|
||||||
|
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
|
||||||
|
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
|
||||||
|
ELEKTRO-L 2
|
||||||
|
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
|
||||||
|
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
|
||||||
|
INSAT-3DR
|
||||||
|
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
|
||||||
|
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
|
||||||
|
HIMAWARI-9
|
||||||
|
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
|
||||||
|
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
|
||||||
|
GOES 16
|
||||||
|
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
|
||||||
|
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
|
||||||
|
FENGYUN 4A
|
||||||
|
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
|
||||||
|
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
|
||||||
|
CYGFM05
|
||||||
|
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
|
||||||
|
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
|
||||||
|
CYGFM04
|
||||||
|
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
|
||||||
|
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
|
||||||
|
CYGFM02
|
||||||
|
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
|
||||||
|
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
|
||||||
|
CYGFM01
|
||||||
|
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
|
||||||
|
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
|
||||||
|
CYGFM08
|
||||||
|
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
|
||||||
|
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
|
||||||
|
CYGFM07
|
||||||
|
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
|
||||||
|
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
|
||||||
|
CYGFM03
|
||||||
|
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
|
||||||
|
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
|
||||||
|
FENGYUN 3D
|
||||||
|
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
|
||||||
|
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
|
||||||
|
NOAA 20 (JPSS-1)
|
||||||
|
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
|
||||||
|
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
|
||||||
|
GOES 17
|
||||||
|
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
|
||||||
|
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
|
||||||
|
FENGYUN 2H
|
||||||
|
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
|
||||||
|
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
|
||||||
|
METOP-C
|
||||||
|
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
|
||||||
|
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
|
||||||
|
GEO-KOMPSAT-2A
|
||||||
|
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
|
||||||
|
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
|
||||||
|
METEOR-M2 2
|
||||||
|
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
|
||||||
|
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
|
||||||
|
ARKTIKA-M 1
|
||||||
|
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
|
||||||
|
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
|
||||||
|
FENGYUN 3E
|
||||||
|
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
|
||||||
|
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
|
||||||
|
GOES 18
|
||||||
|
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
|
||||||
|
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
|
||||||
|
NOAA 21 (JPSS-2)
|
||||||
|
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
|
||||||
|
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
|
||||||
|
METEOSAT-12 (MTG-I1)
|
||||||
|
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
|
||||||
|
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
|
||||||
|
TIANMU-1 03
|
||||||
|
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
|
||||||
|
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
|
||||||
|
TIANMU-1 04
|
||||||
|
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
|
||||||
|
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
|
||||||
|
TIANMU-1 05
|
||||||
|
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
|
||||||
|
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
|
||||||
|
TIANMU-1 06
|
||||||
|
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
|
||||||
|
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
|
||||||
|
FENGYUN 3G
|
||||||
|
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
|
||||||
|
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
|
||||||
|
METEOR-M2 3
|
||||||
|
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
|
||||||
|
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
|
||||||
|
TIANMU-1 07
|
||||||
|
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
|
||||||
|
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
|
||||||
|
TIANMU-1 08
|
||||||
|
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
|
||||||
|
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
|
||||||
|
TIANMU-1 09
|
||||||
|
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
|
||||||
|
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
|
||||||
|
TIANMU-1 10
|
||||||
|
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
|
||||||
|
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
|
||||||
|
FENGYUN 3F
|
||||||
|
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
|
||||||
|
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
|
||||||
|
ARKTIKA-M 2
|
||||||
|
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
|
||||||
|
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
|
||||||
|
TIANMU-1 11
|
||||||
|
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
|
||||||
|
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
|
||||||
|
TIANMU-1 12
|
||||||
|
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
|
||||||
|
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
|
||||||
|
TIANMU-1 13
|
||||||
|
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
|
||||||
|
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
|
||||||
|
TIANMU-1 14
|
||||||
|
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
|
||||||
|
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
|
||||||
|
TIANMU-1 19
|
||||||
|
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
|
||||||
|
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
|
||||||
|
TIANMU-1 20
|
||||||
|
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
|
||||||
|
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
|
||||||
|
TIANMU-1 21
|
||||||
|
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
|
||||||
|
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
|
||||||
|
TIANMU-1 22
|
||||||
|
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
|
||||||
|
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
|
||||||
|
TIANMU-1 15
|
||||||
|
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
|
||||||
|
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
|
||||||
|
TIANMU-1 16
|
||||||
|
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
|
||||||
|
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
|
||||||
|
TIANMU-1 17
|
||||||
|
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
|
||||||
|
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
|
||||||
|
TIANMU-1 18
|
||||||
|
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
|
||||||
|
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
|
||||||
|
INSAT-3DS
|
||||||
|
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
|
||||||
|
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
|
||||||
|
METEOR-M2 4
|
||||||
|
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
|
||||||
|
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
|
||||||
|
GOES 19
|
||||||
|
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
|
||||||
|
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
|
||||||
|
FENGYUN 3H
|
||||||
|
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
|
||||||
|
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
|
||||||
+132
-50
@@ -838,14 +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()),
|
||||||
'bt_devices': list(self.bluetooth_devices.values()),
|
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
||||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
'bt_devices': list(self.bluetooth_devices.values()),
|
||||||
}
|
'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', []),
|
||||||
@@ -1104,23 +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 = []
|
||||||
# Clear reported threat tracking sets
|
self.tscm_wifi_clients = {}
|
||||||
if hasattr(self, '_tscm_reported_wifi'):
|
# Clear reported threat tracking sets
|
||||||
self._tscm_reported_wifi.clear()
|
if hasattr(self, '_tscm_reported_wifi'):
|
||||||
if hasattr(self, '_tscm_reported_bt'):
|
self._tscm_reported_wifi.clear()
|
||||||
self._tscm_reported_bt.clear()
|
if hasattr(self, '_tscm_reported_bt'):
|
||||||
|
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'):
|
||||||
@@ -1540,9 +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')
|
||||||
band = params.get('band', 'abg')
|
channels = params.get('channels')
|
||||||
scan_type = params.get('scan_type', 'deep')
|
band = params.get('band', 'abg')
|
||||||
|
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':
|
||||||
@@ -1571,8 +1574,21 @@ class ModeManager:
|
|||||||
else:
|
else:
|
||||||
scan_band = 'all'
|
scan_band = 'all'
|
||||||
|
|
||||||
# Start deep scan
|
channel_list = None
|
||||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
|
if channels:
|
||||||
|
if isinstance(channels, str):
|
||||||
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
|
channel_list = list(channels)
|
||||||
|
else:
|
||||||
|
channel_list = [channels]
|
||||||
|
try:
|
||||||
|
channel_list = [int(c) for c in channel_list]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'message': 'Invalid channels'}
|
||||||
|
|
||||||
|
# Start deep scan
|
||||||
|
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
||||||
# Start thread to sync data to agent's dictionaries
|
# 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,
|
||||||
@@ -1591,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)
|
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."""
|
||||||
@@ -1630,8 +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(self, interface: str | None, channel: int | None, band: str) -> dict:
|
def _start_wifi_fallback(
|
||||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
self,
|
||||||
|
interface: str | None,
|
||||||
|
channel: int | None,
|
||||||
|
band: str,
|
||||||
|
channels: list[int] | str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
return {'status': 'error', 'message': 'WiFi interface required'}
|
||||||
|
|
||||||
@@ -1658,8 +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')
|
||||||
if channel:
|
channel_list = None
|
||||||
cmd.extend(['-c', str(channel)])
|
if channels:
|
||||||
|
if isinstance(channels, str):
|
||||||
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
|
channel_list = list(channels)
|
||||||
|
else:
|
||||||
|
channel_list = [channels]
|
||||||
|
try:
|
||||||
|
channel_list = [int(c) for c in channel_list]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'message': 'Invalid channels'}
|
||||||
|
|
||||||
|
if channel_list:
|
||||||
|
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||||
|
elif channel:
|
||||||
|
cmd.extend(['-c', str(channel)])
|
||||||
cmd.append(interface)
|
cmd.append(interface)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3111,9 +3148,12 @@ 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 = []
|
||||||
self.tscm_anomalies.clear()
|
if not hasattr(self, 'tscm_wifi_clients'):
|
||||||
|
self.tscm_wifi_clients = {}
|
||||||
|
self.tscm_anomalies.clear()
|
||||||
|
self.tscm_wifi_clients.clear()
|
||||||
|
|
||||||
# Get params for what to scan
|
# Get params for what to scan
|
||||||
scan_wifi = params.get('wifi', True)
|
scan_wifi = params.get('wifi', True)
|
||||||
@@ -3168,7 +3208,7 @@ 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_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
|
||||||
@@ -3202,8 +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_bt = {}
|
seen_wifi_clients = {}
|
||||||
|
seen_bt = {}
|
||||||
|
|
||||||
last_rf_scan = 0
|
last_rf_scan = 0
|
||||||
rf_scan_interval = 30
|
rf_scan_interval = 30
|
||||||
@@ -3261,10 +3302,51 @@ class ModeManager:
|
|||||||
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
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"WiFi scan error: {e}")
|
# WiFi clients (monitor mode only)
|
||||||
|
try:
|
||||||
|
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
||||||
|
for client in wifi_clients:
|
||||||
|
mac = (client.get('mac') or '').upper()
|
||||||
|
if not mac or mac in seen_wifi_clients:
|
||||||
|
continue
|
||||||
|
seen_wifi_clients[mac] = client
|
||||||
|
|
||||||
|
rssi_val = client.get('rssi_current')
|
||||||
|
if rssi_val is None:
|
||||||
|
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
||||||
|
|
||||||
|
client_device = {
|
||||||
|
'mac': mac,
|
||||||
|
'vendor': client.get('vendor'),
|
||||||
|
'name': client.get('vendor') or 'WiFi Client',
|
||||||
|
'rssi': rssi_val,
|
||||||
|
'associated_bssid': client.get('associated_bssid'),
|
||||||
|
'probed_ssids': client.get('probed_ssids', []),
|
||||||
|
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||||
|
'is_client': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._tscm_correlation:
|
||||||
|
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
||||||
|
client_device['classification'] = profile.risk_level.value
|
||||||
|
client_device['score'] = profile.total_score
|
||||||
|
client_device['score_modifier'] = profile.score_modifier
|
||||||
|
client_device['known_device'] = profile.known_device
|
||||||
|
client_device['known_device_name'] = profile.known_device_name
|
||||||
|
client_device['indicators'] = [
|
||||||
|
{'type': i.type.value, 'desc': i.description}
|
||||||
|
for i in profile.indicators
|
||||||
|
]
|
||||||
|
client_device['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
|
self.tscm_wifi_clients[mac] = client_device
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WiFi client scan error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WiFi scan error: {e}")
|
||||||
|
|
||||||
# Bluetooth scan using Intercept's function (same as local mode)
|
# Bluetooth scan using Intercept's function (same as local mode)
|
||||||
if scan_bt:
|
if scan_bt:
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.14.0"
|
version = "2.16.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"
|
||||||
@@ -57,6 +57,7 @@ optionals = [
|
|||||||
"scipy>=1.10.0",
|
"scipy>=1.10.0",
|
||||||
"qrcode[pil]>=7.4",
|
"qrcode[pil]>=7.4",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
|
"Pillow>=9.0.0",
|
||||||
"meshtastic>=2.0.0",
|
"meshtastic>=2.0.0",
|
||||||
"psycopg2-binary>=2.9.9",
|
"psycopg2-binary>=2.9.9",
|
||||||
"scapy>=2.4.5",
|
"scapy>=2.4.5",
|
||||||
|
|||||||
+4
-1
@@ -13,10 +13,13 @@ bleak>=0.21.0
|
|||||||
# Satellite tracking (optional - only needed for satellite features)
|
# Satellite tracking (optional - only needed for satellite features)
|
||||||
skyfield>=1.45
|
skyfield>=1.45
|
||||||
|
|
||||||
# DSC decoding (optional - only needed for VHF DSC maritime distress)
|
# DSC decoding and SSTV decoding (DSP pipeline)
|
||||||
scipy>=1.10.0
|
scipy>=1.10.0
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
|
|
||||||
|
# SSTV image output (optional - needed for SSTV image decoding)
|
||||||
|
Pillow>=9.0.0
|
||||||
|
|
||||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ 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 .recordings import recordings_bp
|
||||||
|
from .subghz import subghz_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -54,9 +58,13 @@ 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(recordings_bp) # Session recordings
|
||||||
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
+26
-4
@@ -20,13 +20,15 @@ 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.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
PROCESS_START_WAIT,
|
PROCESS_START_WAIT,
|
||||||
)
|
)
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
|
||||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||||
|
|
||||||
@@ -144,9 +146,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
logger.error(f"ACARS stream error: {e}")
|
logger.error(f"ACARS stream error: {e}")
|
||||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
|
global acars_active_device
|
||||||
|
# Ensure process is terminated
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(process)
|
||||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/tools')
|
@acars_bp.route('/tools')
|
||||||
@@ -311,6 +328,7 @@ def start_acars() -> Response:
|
|||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
app_module.acars_process = process
|
app_module.acars_process = process
|
||||||
|
register_process(process)
|
||||||
|
|
||||||
# Start output streaming thread
|
# Start output streaming thread
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
@@ -374,9 +392,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()
|
||||||
yield format_sse(msg)
|
try:
|
||||||
|
process_event('acars', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
ADSB_SBS_PORT,
|
ADSB_SBS_PORT,
|
||||||
@@ -843,6 +844,10 @@ def stream_adsb():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('adsb', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from config import SHARED_OBSERVER_LOCATION_ENABLED
|
|||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
AIS_TCP_PORT,
|
AIS_TCP_PORT,
|
||||||
@@ -484,6 +485,10 @@ def stream_ais():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('ais', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Alerting API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.alerts import get_alert_manager
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
|
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules', methods=['GET'])
|
||||||
|
def list_rules():
|
||||||
|
manager = get_alert_manager()
|
||||||
|
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
||||||
|
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules', methods=['POST'])
|
||||||
|
def create_rule():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if not isinstance(data.get('match', {}), dict):
|
||||||
|
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
|
||||||
|
|
||||||
|
manager = get_alert_manager()
|
||||||
|
rule_id = manager.add_rule(data)
|
||||||
|
return jsonify({'status': 'success', 'rule_id': rule_id})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
||||||
|
def update_rule(rule_id: int):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
manager = get_alert_manager()
|
||||||
|
ok = manager.update_rule(rule_id, data)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||||
|
def delete_rule(rule_id: int):
|
||||||
|
manager = get_alert_manager()
|
||||||
|
ok = manager.delete_rule(rule_id)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/events', methods=['GET'])
|
||||||
|
def list_events():
|
||||||
|
manager = get_alert_manager()
|
||||||
|
limit = request.args.get('limit', default=100, type=int)
|
||||||
|
mode = request.args.get('mode')
|
||||||
|
severity = request.args.get('severity')
|
||||||
|
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
||||||
|
return jsonify({'status': 'success', 'events': events})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/stream', methods=['GET'])
|
||||||
|
def stream_alerts() -> Response:
|
||||||
|
manager = get_alert_manager()
|
||||||
|
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
for event in manager.stream_events(timeout=1.0):
|
||||||
|
yield format_sse(event)
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
+114
-79
@@ -19,14 +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.constants import (
|
from utils.event_pipeline import process_event
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
from utils.sdr import SDRFactory, SDRType
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
PROCESS_START_WAIT,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||||
@@ -52,6 +54,7 @@ aprs_packet_count = 0
|
|||||||
aprs_station_count = 0
|
aprs_station_count = 0
|
||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {} # callsign -> station data
|
aprs_stations = {} # callsign -> station data
|
||||||
|
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
|
||||||
|
|
||||||
# Meter rate limiting
|
# Meter rate limiting
|
||||||
_last_meter_time = 0.0
|
_last_meter_time = 0.0
|
||||||
@@ -70,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
|
||||||
@@ -1370,6 +1378,13 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
'last_seen': packet.get('timestamp'),
|
'last_seen': packet.get('timestamp'),
|
||||||
'packet_type': packet.get('packet_type'),
|
'packet_type': packet.get('packet_type'),
|
||||||
}
|
}
|
||||||
|
# Evict oldest stations when limit is exceeded
|
||||||
|
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||||
|
oldest = min(
|
||||||
|
aprs_stations,
|
||||||
|
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||||
|
)
|
||||||
|
del aprs_stations[oldest]
|
||||||
|
|
||||||
app_module.aprs_queue.put(packet)
|
app_module.aprs_queue.put(packet)
|
||||||
|
|
||||||
@@ -1405,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')
|
||||||
@@ -1458,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
|
||||||
@@ -1479,15 +1489,34 @@ 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.reserve_sdr_device(device, 'APRS')
|
error = app_module.claim_sdr_device(device, 'aprs')
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1516,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:
|
||||||
@@ -1660,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}")
|
||||||
@@ -1727,6 +1758,10 @@ def stream_aprs() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('aprs', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+44
-28
@@ -1,10 +1,11 @@
|
|||||||
"""WebSocket-based audio streaming for SDR."""
|
"""WebSocket-based audio streaming for SDR."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# Try to import flask-sock
|
# Try to import flask-sock
|
||||||
@@ -36,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
|
||||||
|
|
||||||
@@ -66,12 +73,6 @@ def kill_audio_processes():
|
|||||||
pass
|
pass
|
||||||
rtl_process = None
|
rtl_process = None
|
||||||
|
|
||||||
# Kill any orphaned processes
|
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,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),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -228,13 +229,13 @@ def init_audio_websocket(app: Flask):
|
|||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = str(e).lower()
|
msg = str(e).lower()
|
||||||
if "connection closed" in msg:
|
if "connection closed" in msg:
|
||||||
logger.info("WebSocket closed by client")
|
logger.info("WebSocket closed by client")
|
||||||
break
|
break
|
||||||
if "timed out" not in msg:
|
if "timed out" not in msg:
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
|
||||||
# Stream audio data if active
|
# Stream audio data if active
|
||||||
if streaming and proc and proc.poll() is None:
|
if streaming and proc and proc.poll() is None:
|
||||||
@@ -257,4 +258,19 @@ def init_audio_websocket(app: Flask):
|
|||||||
finally:
|
finally:
|
||||||
with process_lock:
|
with process_lock:
|
||||||
kill_audio_processes()
|
kill_audio_processes()
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream.
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("WebSocket audio client disconnected")
|
logger.info("WebSocket audio client disconnected")
|
||||||
|
|||||||
+12
-7
@@ -18,10 +18,11 @@ from typing import Any, Generator
|
|||||||
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.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -561,9 +562,13 @@ def stream_bt():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
msg = app_module.bt_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
yield format_sse(msg)
|
try:
|
||||||
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
+177
-75
@@ -7,32 +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 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
|
||||||
|
|
||||||
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
|
||||||
|
_bt_seen_cache: set[str] = set()
|
||||||
|
_bt_session_seen: set[str] = set()
|
||||||
|
_bt_seen_lock = threading.Lock()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE FUNCTIONS
|
# DATABASE FUNCTIONS
|
||||||
@@ -164,13 +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]:
|
||||||
|
"""Load distinct device IDs from history for seen-before tracking."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
||||||
|
return {row['device_id'] for row in cursor}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -191,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.
|
||||||
|
|
||||||
@@ -221,17 +236,42 @@ def start_scan():
|
|||||||
# Get scanner instance
|
# Get scanner instance
|
||||||
scanner = get_bluetooth_scanner(adapter_id)
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
|
|
||||||
# Check if already scanning
|
# Initialize database tables if needed
|
||||||
if scanner.is_scanning:
|
init_bt_tables()
|
||||||
return jsonify({
|
|
||||||
'status': 'already_running',
|
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
||||||
'scan_status': scanner.get_status().to_dict()
|
try:
|
||||||
})
|
with _bt_seen_lock:
|
||||||
|
device.seen_before = device.device_id in _bt_seen_cache
|
||||||
# Initialize database tables if needed
|
if device.device_id not in _bt_session_seen:
|
||||||
init_bt_tables()
|
save_observation_history(device)
|
||||||
|
_bt_session_seen.add(device.device_id)
|
||||||
# Load active baseline if exists
|
except Exception as e:
|
||||||
|
logger.debug(f"BT seen-before update failed: {e}")
|
||||||
|
|
||||||
|
# Setup seen-before callback
|
||||||
|
if scanner._on_device_updated is None:
|
||||||
|
scanner._on_device_updated = _handle_seen_before
|
||||||
|
|
||||||
|
# Ensure cache is initialized
|
||||||
|
with _bt_seen_lock:
|
||||||
|
if not _bt_seen_cache:
|
||||||
|
_bt_seen_cache.update(load_seen_device_ids())
|
||||||
|
|
||||||
|
# Check if already scanning
|
||||||
|
if scanner.is_scanning:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'scan_status': scanner.get_status().to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Refresh seen-before cache and reset session set for a new scan
|
||||||
|
with _bt_seen_lock:
|
||||||
|
_bt_seen_cache.clear()
|
||||||
|
_bt_seen_cache.update(load_seen_device_ids())
|
||||||
|
_bt_session_seen.clear()
|
||||||
|
|
||||||
|
# Load active baseline if exists
|
||||||
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)
|
||||||
@@ -856,11 +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)
|
||||||
yield format_sse(event_data, event=event_name)
|
try:
|
||||||
|
process_event('bluetooth', event_data, event_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(event_data, event=event_name)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
event_generator(),
|
event_generator(),
|
||||||
@@ -944,23 +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:
|
||||||
device_data = {
|
manufacturer_name = device.manufacturer_name
|
||||||
'mac': device.address,
|
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
||||||
'address_type': device.address_type,
|
if device.address and not device.is_randomized_mac:
|
||||||
'device_key': device.device_key,
|
try:
|
||||||
'name': device.name or 'Unknown',
|
from data.oui import get_manufacturer
|
||||||
'rssi': device.rssi_current or -100,
|
oui_vendor = get_manufacturer(device.address)
|
||||||
'rssi_median': device.rssi_median,
|
if oui_vendor and oui_vendor != 'Unknown':
|
||||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
manufacturer_name = oui_vendor
|
||||||
'type': _classify_device_type(device),
|
except Exception:
|
||||||
'manufacturer': device.manufacturer_name,
|
pass
|
||||||
'manufacturer_id': device.manufacturer_id,
|
|
||||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
device_data = {
|
||||||
'protocol': device.protocol,
|
'mac': device.address,
|
||||||
'first_seen': device.first_seen.isoformat(),
|
'address_type': device.address_type,
|
||||||
|
'device_key': device.device_key,
|
||||||
|
'name': device.name or 'Unknown',
|
||||||
|
'rssi': device.rssi_current or -100,
|
||||||
|
'rssi_median': device.rssi_median,
|
||||||
|
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||||
|
'type': _classify_device_type(device),
|
||||||
|
'manufacturer': manufacturer_name,
|
||||||
|
'manufacturer_id': device.manufacturer_id,
|
||||||
|
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||||
|
'protocol': device.protocol,
|
||||||
|
'first_seen': device.first_seen.isoformat(),
|
||||||
'last_seen': device.last_seen.isoformat(),
|
'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,
|
||||||
@@ -1174,14 +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 []
|
||||||
# Check by name patterns
|
|
||||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
||||||
return 'audio'
|
if device.address and not device.is_randomized_mac:
|
||||||
|
try:
|
||||||
|
from data.oui import get_manufacturer
|
||||||
|
oui_vendor = get_manufacturer(device.address)
|
||||||
|
if oui_vendor and oui_vendor != 'Unknown':
|
||||||
|
manufacturer_lower = oui_vendor.lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def normalize_uuid(uuid: str) -> str:
|
||||||
|
if not uuid:
|
||||||
|
return ''
|
||||||
|
value = str(uuid).lower().strip()
|
||||||
|
if value.startswith('0x'):
|
||||||
|
value = value[2:]
|
||||||
|
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
||||||
|
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
||||||
|
return value[4:8]
|
||||||
|
if len(value) == 4:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Check by name patterns
|
||||||
|
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||||
|
return 'audio'
|
||||||
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
|
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']):
|
||||||
@@ -1190,18 +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'
|
||||||
|
|
||||||
# Check by manufacturer
|
# Tracker signals (metadata or Find My service)
|
||||||
if 'apple' in manufacturer_lower:
|
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
||||||
return 'apple_device'
|
return 'tracker'
|
||||||
if 'samsung' in manufacturer_lower:
|
|
||||||
return 'samsung_device'
|
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
||||||
|
if 'fd6f' in normalized_uuids:
|
||||||
|
return 'tracker'
|
||||||
|
|
||||||
|
# Service UUIDs (GATT / classic)
|
||||||
|
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
||||||
|
wearable_uuids = {'180d', '1814', '1816'}
|
||||||
|
hid_uuids = {'1812'}
|
||||||
|
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
||||||
|
|
||||||
|
if normalized_uuids & audio_uuids:
|
||||||
|
return 'audio'
|
||||||
|
if normalized_uuids & hid_uuids:
|
||||||
|
return 'peripheral'
|
||||||
|
if normalized_uuids & wearable_uuids:
|
||||||
|
return 'wearable'
|
||||||
|
if normalized_uuids & beacon_uuids:
|
||||||
|
return 'beacon'
|
||||||
|
|
||||||
|
# Check by manufacturer
|
||||||
|
if 'apple' in manufacturer_lower:
|
||||||
|
return 'apple_device'
|
||||||
|
if 'samsung' in manufacturer_lower:
|
||||||
|
return 'samsung_device'
|
||||||
|
|
||||||
# Check by class of device
|
# Check by class of device
|
||||||
if device.major_class:
|
if device.major_class:
|
||||||
|
|||||||
+481
-126
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import select
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -17,6 +18,10 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
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.event_pipeline import process_event
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -35,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
|
||||||
@@ -51,14 +64,25 @@ _DSD_PROTOCOL_FLAGS = {
|
|||||||
'provoice': ['-fv'],
|
'provoice': ['-fv'],
|
||||||
}
|
}
|
||||||
|
|
||||||
# dsd-fme uses different flag names
|
# dsd-fme remapped several flags from classic DSD:
|
||||||
|
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
|
||||||
|
# -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'],
|
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
|
||||||
'dmr': ['-fs'],
|
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||||
'p25': ['-f1'],
|
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
|
||||||
'nxdn': ['-fi'],
|
'nxdn': ['-fn'], # NXDN96
|
||||||
'dstar': [],
|
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
|
||||||
'provoice': ['-fp'],
|
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modulation hints: force C4FM for protocols that use it, improving
|
||||||
|
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||||
|
_DSD_FME_MODULATION = {
|
||||||
|
'dmr': ['-mc'], # C4FM
|
||||||
|
'nxdn': ['-mc'], # C4FM
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -86,103 +110,298 @@ 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.
|
||||||
|
|
||||||
|
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
|
||||||
|
different formatting for talkgroup / source / voice frame lines.
|
||||||
|
"""
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
|
||||||
|
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
|
||||||
|
# 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
|
||||||
|
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
||||||
|
return None
|
||||||
|
|
||||||
|
ts = datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||||
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
||||||
if sync_match:
|
if sync_match:
|
||||||
return {
|
return {
|
||||||
'type': 'sync',
|
'type': 'sync',
|
||||||
'protocol': sync_match.group(1).strip(),
|
'protocol': sync_match.group(1).strip(),
|
||||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
'timestamp': ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Talkgroup and Source: "TG: 12345 Src: 67890"
|
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
|
||||||
tg_match = re.match(r'.*TG:\s*(\d+)\s+Src:\s*(\d+)', line)
|
# is captured as a call event rather than a bare slot event.
|
||||||
|
# Classic dsd: "TG: 12345 Src: 67890"
|
||||||
|
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
|
||||||
|
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
|
||||||
|
tg_match = re.search(
|
||||||
|
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
|
||||||
|
)
|
||||||
if tg_match:
|
if tg_match:
|
||||||
return {
|
result = {
|
||||||
'type': 'call',
|
'type': 'call',
|
||||||
'talkgroup': int(tg_match.group(1)),
|
'talkgroup': int(tg_match.group(1)),
|
||||||
'source_id': int(tg_match.group(2)),
|
'source_id': int(tg_match.group(2)),
|
||||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
'timestamp': ts,
|
||||||
}
|
}
|
||||||
|
# Extract slot if present on the same line
|
||||||
|
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||||
|
if slot_inline:
|
||||||
|
result['slot'] = int(slot_inline.group(1))
|
||||||
|
return result
|
||||||
|
|
||||||
# Slot info: "Slot 1" or "Slot 2"
|
# P25 NAC (Network Access Code) — check before voice/slot
|
||||||
slot_match = re.match(r'.*Slot\s*(\d+)', line)
|
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
|
||||||
if slot_match:
|
|
||||||
return {
|
|
||||||
'type': 'slot',
|
|
||||||
'slot': int(slot_match.group(1)),
|
|
||||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# DMR voice frame
|
|
||||||
if 'Voice' in line or 'voice' in line:
|
|
||||||
return {
|
|
||||||
'type': 'voice',
|
|
||||||
'detail': line,
|
|
||||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# P25 NAC (Network Access Code)
|
|
||||||
nac_match = re.match(r'.*NAC:\s*(\w+)', line)
|
|
||||||
if nac_match:
|
if nac_match:
|
||||||
return {
|
return {
|
||||||
'type': 'nac',
|
'type': 'nac',
|
||||||
'nac': nac_match.group(1),
|
'nac': nac_match.group(1),
|
||||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
'timestamp': ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
# Voice frame detection — check BEFORE bare slot match
|
||||||
|
# Classic dsd: "Voice" keyword in frame lines
|
||||||
|
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
|
||||||
|
if re.search(r'\bvoice\b', line, re.IGNORECASE):
|
||||||
|
result = {
|
||||||
|
'type': 'voice',
|
||||||
|
'detail': line,
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||||
|
if slot_inline:
|
||||||
|
result['slot'] = int(slot_inline.group(1))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Bare slot info (only when line is *just* slot info, not voice/call)
|
||||||
|
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
|
||||||
|
if slot_match:
|
||||||
|
return {
|
||||||
|
'type': 'slot',
|
||||||
|
'slot': int(slot_match.group(1)),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
|
||||||
|
# Also catches "Closing", "Input", and other lifecycle lines.
|
||||||
|
# Forward as raw so the frontend can show decoder is alive.
|
||||||
|
return {
|
||||||
|
'type': 'raw',
|
||||||
|
'text': line[:200],
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_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):
|
||||||
|
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||||
|
try:
|
||||||
|
dmr_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
dmr_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
dmr_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||||
"""Read DSD stderr output and push parsed events to the queue."""
|
"""Read DSD stderr output and push parsed events to the queue.
|
||||||
|
|
||||||
|
Uses select() with a timeout so we can send periodic heartbeat
|
||||||
|
events while readline() would otherwise block indefinitely during
|
||||||
|
silence (no signal being decoded).
|
||||||
|
"""
|
||||||
global dmr_running
|
global dmr_running
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dmr_queue.put_nowait({'type': 'status', 'text': 'started'})
|
_queue_put({'type': 'status', 'text': 'started'})
|
||||||
|
last_heartbeat = time.time()
|
||||||
|
|
||||||
while dmr_running:
|
while dmr_running:
|
||||||
if dsd_process.poll() is not None:
|
if dsd_process.poll() is not None:
|
||||||
break
|
break
|
||||||
|
|
||||||
line = dsd_process.stderr.readline()
|
# Wait up to 1s for data on stderr instead of blocking forever
|
||||||
if not line:
|
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
|
||||||
if dsd_process.poll() is not None:
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
|
|
||||||
text = line.decode('utf-8', errors='replace').strip()
|
if ready:
|
||||||
if not text:
|
line = dsd_process.stderr.readline()
|
||||||
continue
|
if not line:
|
||||||
|
if dsd_process.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
parsed = parse_dsd_output(text)
|
text = line.decode('utf-8', errors='replace').strip()
|
||||||
if parsed:
|
if not text:
|
||||||
try:
|
continue
|
||||||
dmr_queue.put_nowait(parsed)
|
|
||||||
except queue.Full:
|
logger.debug("DSD raw: %s", text)
|
||||||
try:
|
parsed = parse_dsd_output(text)
|
||||||
dmr_queue.get_nowait()
|
if parsed:
|
||||||
except queue.Empty:
|
_queue_put(parsed)
|
||||||
pass
|
last_heartbeat = time.time()
|
||||||
try:
|
else:
|
||||||
dmr_queue.put_nowait(parsed)
|
# No stderr output — send heartbeat so frontend knows
|
||||||
except queue.Full:
|
# decoder is still alive and listening
|
||||||
pass
|
now = time.time()
|
||||||
|
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
|
||||||
|
_queue_put({
|
||||||
|
'type': 'heartbeat',
|
||||||
|
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||||
|
})
|
||||||
|
last_heartbeat = now
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
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_has_audio
|
||||||
dmr_running = False
|
dmr_running = False
|
||||||
try:
|
dmr_has_audio = False
|
||||||
dmr_queue.put_nowait({'type': 'status', 'text': 'stopped'})
|
with _ffmpeg_sinks_lock:
|
||||||
except queue.Full:
|
_ffmpeg_sinks.clear()
|
||||||
pass
|
# Capture exit info for diagnostics
|
||||||
|
rc = dsd_process.poll()
|
||||||
|
reason = 'stopped'
|
||||||
|
detail = ''
|
||||||
|
if rc is not None and rc != 0:
|
||||||
|
reason = 'crashed'
|
||||||
|
try:
|
||||||
|
remaining = dsd_process.stderr.read(1024)
|
||||||
|
if remaining:
|
||||||
|
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||||
|
# Cleanup decoder + demod processes
|
||||||
|
_stop_process(dsd_process)
|
||||||
|
_stop_process(rtl_process)
|
||||||
|
dmr_rtl_process = None
|
||||||
|
dmr_dsd_process = None
|
||||||
|
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||||
|
# Release SDR device
|
||||||
|
if dmr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
|
dmr_active_device = None
|
||||||
logger.info("DSD stream thread stopped")
|
logger.info("DSD stream thread stopped")
|
||||||
|
|
||||||
|
|
||||||
@@ -195,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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -206,36 +429,43 @@ 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:
|
||||||
frequency = float(data.get('frequency', 462.5625))
|
frequency = validate_frequency(data.get('frequency', 462.5625))
|
||||||
gain = int(data.get('gain', 40))
|
gain = int(validate_gain(data.get('gain', 40)))
|
||||||
device = int(data.get('device', 0))
|
device = validate_device_index(data.get('device', 0))
|
||||||
protocol = str(data.get('protocol', 'auto')).lower()
|
protocol = str(data.get('protocol', 'auto')).lower()
|
||||||
|
ppm = validate_ppm(data.get('ppm', 0))
|
||||||
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
|
||||||
|
|
||||||
if frequency <= 0:
|
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400
|
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:
|
||||||
@@ -243,32 +473,67 @@ def start_dmr() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Claim SDR device
|
# Reserve running state before we start claiming resources/processes
|
||||||
error = app_module.claim_sdr_device(device, 'dmr')
|
# so concurrent /start requests cannot race each other.
|
||||||
|
with dmr_lock:
|
||||||
|
if dmr_running:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||||
|
dmr_running = True
|
||||||
|
dmr_has_audio = False
|
||||||
|
|
||||||
|
# Claim SDR device — use protocol name so the device panel shows
|
||||||
|
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||||
|
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||||
|
error = app_module.claim_sdr_device(device, mode_label)
|
||||||
if error:
|
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)
|
||||||
rtl_cmd = [
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
rtl_fm_path,
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
'-M', 'fm',
|
device=sdr_device,
|
||||||
'-f', str(freq_hz),
|
frequency_mhz=frequency,
|
||||||
'-s', '48000',
|
sample_rate=48000,
|
||||||
'-g', str(gain),
|
gain=float(gain) if gain > 0 else None,
|
||||||
'-d', str(device),
|
ppm=int(ppm) if ppm != 0 else None,
|
||||||
'-l', '1', # squelch level
|
modulation='fm',
|
||||||
]
|
squelch=None,
|
||||||
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
|
)
|
||||||
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
|
# Keep squelch fully open for digital bitstreams.
|
||||||
|
rtl_cmd.extend(['-l', '0'])
|
||||||
|
except Exception as e:
|
||||||
|
_reset_runtime_state(release_device=True)
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||||
|
|
||||||
# Build DSD command
|
# 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, []))
|
||||||
|
# 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
|
||||||
|
# through at the cost of occasional decode errors.
|
||||||
|
if data.get('relaxCrc', False):
|
||||||
|
dsd_cmd.append('-F')
|
||||||
else:
|
else:
|
||||||
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
|
|
||||||
@@ -278,17 +543,33 @@ def start_dmr() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
# 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()
|
||||||
@@ -302,14 +583,16 @@ 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}")
|
||||||
if dmr_active_device is not None:
|
# Terminate surviving processes and release resources.
|
||||||
app_module.release_sdr_device(dmr_active_device)
|
_reset_runtime_state(release_device=True)
|
||||||
dmr_active_device = None
|
# Surface a clear error to the user
|
||||||
# Surface the most relevant error to the user
|
|
||||||
detail = rtl_err.strip() or dsd_err.strip()
|
detail = rtl_err.strip() or dsd_err.strip()
|
||||||
msg = 'Failed to start DSD pipeline'
|
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||||
if detail:
|
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
|
||||||
msg += f': {detail}'
|
elif detail:
|
||||||
|
msg = f'Failed to start DSD pipeline: {detail}'
|
||||||
|
else:
|
||||||
|
msg = 'Failed to start DSD pipeline'
|
||||||
return jsonify({'status': 'error', 'message': msg}), 500
|
return jsonify({'status': 'error', 'message': msg}), 500
|
||||||
|
|
||||||
# Drain rtl_fm stderr in background to prevent pipe blocking
|
# Drain rtl_fm stderr in background to prevent pipe blocking
|
||||||
@@ -322,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),
|
||||||
@@ -334,40 +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:
|
||||||
|
_reset_runtime_state(release_device=True)
|
||||||
dmr_running = False
|
|
||||||
|
|
||||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
|
||||||
if proc and proc.poll() is None:
|
|
||||||
try:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=2)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
proc.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
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'})
|
||||||
|
|
||||||
@@ -378,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."""
|
||||||
@@ -390,6 +741,10 @@ def stream_dmr() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('dmr', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+45
-2
@@ -36,9 +36,11 @@ from utils.database import (
|
|||||||
)
|
)
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dsc')
|
logger = logging.getLogger('intercept.dsc')
|
||||||
|
|
||||||
@@ -169,17 +171,34 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
|
global dsc_active_device
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
decoder_process.wait()
|
|
||||||
dsc_running = False
|
dsc_running = False
|
||||||
|
# Cleanup both processes
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
rtl_proc = app_module.dsc_rtl_process
|
||||||
|
for proc in [rtl_proc, decoder_process]:
|
||||||
|
if proc:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(proc)
|
||||||
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
app_module.dsc_process = None
|
app_module.dsc_process = None
|
||||||
app_module.dsc_rtl_process = None
|
app_module.dsc_rtl_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
|
|
||||||
|
|
||||||
def _store_critical_alert(msg: dict) -> None:
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
@@ -362,6 +381,7 @@ def start_decoding() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(rtl_process)
|
||||||
|
|
||||||
# Start stderr monitor thread
|
# Start stderr monitor thread
|
||||||
stderr_thread = threading.Thread(
|
stderr_thread = threading.Thread(
|
||||||
@@ -382,6 +402,7 @@ def start_decoding() -> Response:
|
|||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
)
|
)
|
||||||
|
register_process(decoder_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
rtl_process.stdout.close()
|
rtl_process.stdout.close()
|
||||||
@@ -408,6 +429,15 @@ def start_decoding() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
# Kill orphaned rtl_fm process
|
||||||
|
try:
|
||||||
|
rtl_process.terminate()
|
||||||
|
rtl_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device)
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
@@ -417,6 +447,15 @@ def start_decoding() -> Response:
|
|||||||
'message': f'Tool not found: {e.filename}'
|
'message': f'Tool not found: {e.filename}'
|
||||||
}), 400
|
}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Kill orphaned rtl_fm process if it was started
|
||||||
|
try:
|
||||||
|
rtl_process.terminate()
|
||||||
|
rtl_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device)
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
@@ -487,6 +526,10 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.dsc_queue.get(timeout=1)
|
msg = app_module.dsc_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('dsc', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+267
-131
@@ -20,6 +20,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -101,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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -206,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):
|
||||||
@@ -678,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):
|
||||||
@@ -839,9 +846,13 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
try:
|
try:
|
||||||
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
||||||
if not ready:
|
if not ready:
|
||||||
logger.warning("Audio pipeline produced no data in startup window")
|
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
|
||||||
|
_stop_audio_stream_internal()
|
||||||
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Audio startup check failed: {e}")
|
logger.warning(f"Audio startup check failed: {e}")
|
||||||
|
_stop_audio_stream_internal()
|
||||||
|
return
|
||||||
|
|
||||||
audio_running = True
|
audio_running = True
|
||||||
audio_frequency = frequency
|
audio_frequency = frequency
|
||||||
@@ -866,6 +877,8 @@ def _stop_audio_stream_internal():
|
|||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
audio_frequency = 0.0
|
||||||
|
|
||||||
|
had_processes = audio_process is not None or audio_rtl_process is not None
|
||||||
|
|
||||||
# Kill the pipeline processes and their groups
|
# Kill the pipeline processes and their groups
|
||||||
if audio_process:
|
if audio_process:
|
||||||
try:
|
try:
|
||||||
@@ -891,19 +904,9 @@ def _stop_audio_stream_internal():
|
|||||||
audio_process = None
|
audio_process = None
|
||||||
audio_rtl_process = None
|
audio_rtl_process = None
|
||||||
|
|
||||||
# Kill any orphaned rtl_fm, rtl_power, and ffmpeg processes
|
|
||||||
for proc_pattern in ['rtl_fm', 'rtl_power']:
|
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', proc_pattern], capture_output=True, timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Pause for SDR device USB interface to be released by kernel
|
# Pause for SDR device USB interface to be released by kernel
|
||||||
time.sleep(1.0)
|
if had_processes:
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -1186,6 +1189,10 @@ def stream_scanner_events() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('listening_scanner', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1304,11 +1311,40 @@ def start_audio() -> Response:
|
|||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
# Claim device for listening audio
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
|
if waterfall_running and waterfall_active_device == device:
|
||||||
|
_stop_waterfall_internal()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Claim device for listening audio. The WebSocket waterfall handler
|
||||||
|
# may still be tearing down its IQ capture process (thread join +
|
||||||
|
# safe_terminate can take several seconds), so we retry with back-off
|
||||||
|
# to give the USB device time to be fully released.
|
||||||
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',
|
||||||
@@ -1411,13 +1447,6 @@ def audio_probe() -> Response:
|
|||||||
@listening_post_bp.route('/audio/stream')
|
@listening_post_bp.route('/audio/stream')
|
||||||
def stream_audio() -> Response:
|
def stream_audio() -> Response:
|
||||||
"""Stream WAV audio."""
|
"""Stream WAV audio."""
|
||||||
# Optionally restart pipeline so the stream starts with a fresh header
|
|
||||||
if request.args.get('fresh') == '1' and audio_running:
|
|
||||||
try:
|
|
||||||
_start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm')
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Audio stream restart failed: {e}")
|
|
||||||
|
|
||||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if audio_running and audio_process:
|
if audio_running and audio_process:
|
||||||
@@ -1433,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:
|
||||||
@@ -1532,9 +1578,51 @@ waterfall_config = {
|
|||||||
'bin_size': 10000,
|
'bin_size': 10000,
|
||||||
'gain': 40,
|
'gain': 40,
|
||||||
'device': 0,
|
'device': 0,
|
||||||
|
'max_bins': 1024,
|
||||||
|
'interval': 0.4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
|
||||||
|
"""Parse a single rtl_power CSV line into bins."""
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
return None, None, None, []
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
if len(parts) < 6:
|
||||||
|
return None, None, None, []
|
||||||
|
|
||||||
|
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
|
||||||
|
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
|
||||||
|
|
||||||
|
start_idx = None
|
||||||
|
for i, tok in enumerate(parts):
|
||||||
|
try:
|
||||||
|
val = float(tok)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if val > 1e5:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None or len(parts) < start_idx + 4:
|
||||||
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
seg_start = float(parts[start_idx])
|
||||||
|
seg_end = float(parts[start_idx + 1])
|
||||||
|
raw_values = []
|
||||||
|
for v in parts[start_idx + 3:]:
|
||||||
|
try:
|
||||||
|
raw_values.append(float(v))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
|
raw_values = raw_values[1:]
|
||||||
|
return timestamp, seg_start, seg_end, raw_values
|
||||||
|
except ValueError:
|
||||||
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_loop():
|
def _waterfall_loop():
|
||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
global waterfall_running, waterfall_process
|
global waterfall_running, waterfall_process
|
||||||
@@ -1545,84 +1633,59 @@ def _waterfall_loop():
|
|||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
|
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
||||||
|
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
||||||
|
bin_hz = int(waterfall_config['bin_size'])
|
||||||
|
gain = waterfall_config['gain']
|
||||||
|
device = waterfall_config['device']
|
||||||
|
interval = float(waterfall_config.get('interval', 0.4))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
rtl_power_path,
|
||||||
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
|
'-i', str(interval),
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while waterfall_running:
|
waterfall_process = subprocess.Popen(
|
||||||
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
cmd,
|
||||||
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
stdout=subprocess.PIPE,
|
||||||
bin_hz = int(waterfall_config['bin_size'])
|
stderr=subprocess.DEVNULL,
|
||||||
gain = waterfall_config['gain']
|
bufsize=1,
|
||||||
device = waterfall_config['device']
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
cmd = [
|
current_ts = None
|
||||||
rtl_power_path,
|
all_bins: list[float] = []
|
||||||
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
sweep_start_hz = start_hz
|
||||||
'-i', '0.5',
|
sweep_end_hz = end_hz
|
||||||
'-1',
|
|
||||||
'-g', str(gain),
|
|
||||||
'-d', str(device),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
if not waterfall_process.stdout:
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
return
|
||||||
waterfall_process = proc
|
|
||||||
stdout, _ = proc.communicate(timeout=15)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
stdout = b''
|
|
||||||
finally:
|
|
||||||
waterfall_process = None
|
|
||||||
|
|
||||||
|
for line in waterfall_process.stdout:
|
||||||
if not waterfall_running:
|
if not waterfall_running:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not stdout:
|
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||||
time.sleep(0.2)
|
if ts is None or not bins:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse rtl_power CSV output
|
if current_ts is None:
|
||||||
all_bins = []
|
current_ts = ts
|
||||||
sweep_start_hz = start_hz
|
|
||||||
sweep_end_hz = end_hz
|
|
||||||
|
|
||||||
for line in stdout.decode(errors='ignore').splitlines():
|
if ts != current_ts and all_bins:
|
||||||
if not line or line.startswith('#'):
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
continue
|
bins_to_send = all_bins
|
||||||
parts = [p.strip() for p in line.split(',')]
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
start_idx = None
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
for i, tok in enumerate(parts):
|
|
||||||
try:
|
|
||||||
val = float(tok)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if val > 1e5:
|
|
||||||
start_idx = i
|
|
||||||
break
|
|
||||||
if start_idx is None or len(parts) < start_idx + 4:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
seg_start = float(parts[start_idx])
|
|
||||||
seg_end = float(parts[start_idx + 1])
|
|
||||||
seg_bin = float(parts[start_idx + 2])
|
|
||||||
raw_values = []
|
|
||||||
for v in parts[start_idx + 3:]:
|
|
||||||
try:
|
|
||||||
raw_values.append(float(v))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
|
||||||
raw_values = raw_values[1:]
|
|
||||||
all_bins.extend(raw_values)
|
|
||||||
sweep_start_hz = min(sweep_start_hz, seg_start)
|
|
||||||
sweep_end_hz = max(sweep_end_hz, seg_end)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if all_bins:
|
|
||||||
msg = {
|
msg = {
|
||||||
'type': 'waterfall_sweep',
|
'type': 'waterfall_sweep',
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
'bins': all_bins,
|
'bins': bins_to_send,
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -1637,15 +1700,73 @@ def _waterfall_loop():
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
time.sleep(0.1)
|
all_bins = []
|
||||||
|
sweep_start_hz = start_hz
|
||||||
|
sweep_end_hz = end_hz
|
||||||
|
current_ts = ts
|
||||||
|
|
||||||
|
all_bins.extend(bins)
|
||||||
|
if seg_start is not None:
|
||||||
|
sweep_start_hz = min(sweep_start_hz, seg_start)
|
||||||
|
if seg_end is not None:
|
||||||
|
sweep_end_hz = max(sweep_end_hz, seg_end)
|
||||||
|
|
||||||
|
# Flush any remaining bins
|
||||||
|
if all_bins and waterfall_running:
|
||||||
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
|
bins_to_send = all_bins
|
||||||
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
|
msg = {
|
||||||
|
'type': 'waterfall_sweep',
|
||||||
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
|
'bins': bins_to_send,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
waterfall_queue.put_nowait(msg)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
finally:
|
finally:
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
waterfall_process.terminate()
|
||||||
|
waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
waterfall_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
waterfall_process = None
|
||||||
logger.info("Waterfall loop stopped")
|
logger.info("Waterfall loop stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_waterfall_internal() -> None:
|
||||||
|
"""Stop the waterfall display and release resources."""
|
||||||
|
global waterfall_running, waterfall_process, waterfall_active_device
|
||||||
|
|
||||||
|
waterfall_running = False
|
||||||
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
waterfall_process.terminate()
|
||||||
|
waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
waterfall_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
waterfall_process = None
|
||||||
|
|
||||||
|
if waterfall_active_device is not None:
|
||||||
|
app_module.release_sdr_device(waterfall_active_device)
|
||||||
|
waterfall_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
||||||
def start_waterfall() -> Response:
|
def start_waterfall() -> Response:
|
||||||
"""Start the waterfall/spectrogram display."""
|
"""Start the waterfall/spectrogram display."""
|
||||||
@@ -1666,6 +1787,16 @@ def start_waterfall() -> Response:
|
|||||||
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||||
waterfall_config['gain'] = int(data.get('gain', 40))
|
waterfall_config['gain'] = int(data.get('gain', 40))
|
||||||
waterfall_config['device'] = int(data.get('device', 0))
|
waterfall_config['device'] = int(data.get('device', 0))
|
||||||
|
if data.get('interval') is not None:
|
||||||
|
interval = float(data.get('interval', waterfall_config['interval']))
|
||||||
|
if interval < 0.1 or interval > 5:
|
||||||
|
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
|
||||||
|
waterfall_config['interval'] = interval
|
||||||
|
if data.get('max_bins') is not None:
|
||||||
|
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
|
||||||
|
if max_bins < 64 or max_bins > 4096:
|
||||||
|
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
|
||||||
|
waterfall_config['max_bins'] = max_bins
|
||||||
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
|
||||||
|
|
||||||
@@ -1695,23 +1826,7 @@ def start_waterfall() -> Response:
|
|||||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
||||||
def stop_waterfall() -> Response:
|
def stop_waterfall() -> Response:
|
||||||
"""Stop the waterfall display."""
|
"""Stop the waterfall display."""
|
||||||
global waterfall_running, waterfall_process, waterfall_active_device
|
_stop_waterfall_internal()
|
||||||
|
|
||||||
waterfall_running = False
|
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
|
||||||
try:
|
|
||||||
waterfall_process.terminate()
|
|
||||||
waterfall_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
waterfall_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
waterfall_process = None
|
|
||||||
|
|
||||||
if waterfall_active_device is not None:
|
|
||||||
app_module.release_sdr_device(waterfall_active_device)
|
|
||||||
waterfall_active_device = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -1725,6 +1840,10 @@ def stream_waterfall() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('waterfall', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1736,3 +1855,20 @@ def stream_waterfall() -> Response:
|
|||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
||||||
|
"""Downsample bins to a target length using simple averaging."""
|
||||||
|
if target <= 0 or len(values) <= target:
|
||||||
|
return values
|
||||||
|
|
||||||
|
out: list[float] = []
|
||||||
|
step = len(values) / target
|
||||||
|
for i in range(target):
|
||||||
|
start = int(i * step)
|
||||||
|
end = int((i + 1) * step)
|
||||||
|
if end <= start:
|
||||||
|
end = min(start + 1, len(values))
|
||||||
|
chunk = values[start:end]
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
out.append(sum(chunk) / len(chunk))
|
||||||
|
return out
|
||||||
|
|||||||
+4
-1
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
|
|||||||
'offline.enabled': False,
|
'offline.enabled': False,
|
||||||
'offline.assets_source': 'cdn',
|
'offline.assets_source': 'cdn',
|
||||||
'offline.fonts_source': 'cdn',
|
'offline.fonts_source': 'cdn',
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +44,9 @@ ASSET_PATHS = {
|
|||||||
'static/vendor/leaflet/images/marker-shadow.png',
|
'static/vendor/leaflet/images/marker-shadow.png',
|
||||||
'static/vendor/leaflet/images/layers.png',
|
'static/vendor/leaflet/images/layers.png',
|
||||||
'static/vendor/leaflet/images/layers-2x.png'
|
'static/vendor/leaflet/images/layers-2x.png'
|
||||||
|
],
|
||||||
|
'leaflet_heat': [
|
||||||
|
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+125
-4
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import pty
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import select
|
import select
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -23,7 +25,8 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.process import safe_terminate, register_process
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
@@ -105,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def audio_relay_thread(
|
||||||
|
rtl_stdout,
|
||||||
|
multimon_stdin,
|
||||||
|
output_queue: queue.Queue,
|
||||||
|
stop_event: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||||
|
|
||||||
|
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||||
|
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||||
|
event onto *output_queue*.
|
||||||
|
"""
|
||||||
|
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||||
|
INTERVAL = 0.1 # seconds between scope updates
|
||||||
|
last_scope = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
data = rtl_stdout.read(CHUNK)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Forward audio untouched
|
||||||
|
try:
|
||||||
|
multimon_stdin.write(data)
|
||||||
|
multimon_stdin.flush()
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Compute scope levels every ~100 ms
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_scope >= INTERVAL:
|
||||||
|
last_scope = now
|
||||||
|
try:
|
||||||
|
n_samples = len(data) // 2
|
||||||
|
if n_samples == 0:
|
||||||
|
continue
|
||||||
|
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||||
|
peak = max(abs(s) for s in samples)
|
||||||
|
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||||
|
output_queue.put_nowait({
|
||||||
|
'type': 'scope',
|
||||||
|
'rms': rms,
|
||||||
|
'peak': peak,
|
||||||
|
})
|
||||||
|
except (struct.error, ValueError, queue.Full):
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Audio relay error: {e}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
multimon_stdin.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||||
try:
|
try:
|
||||||
@@ -146,14 +205,37 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
|
global pager_active_device
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
process.wait()
|
# Signal relay thread to stop
|
||||||
|
with app_module.process_lock:
|
||||||
|
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||||
|
if stop_relay:
|
||||||
|
stop_relay.set()
|
||||||
|
# Cleanup companion rtl_fm process and decoder
|
||||||
|
with app_module.process_lock:
|
||||||
|
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||||
|
for proc in [rtl_proc, process]:
|
||||||
|
if proc:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(proc)
|
||||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/start', methods=['POST'])
|
@pager_bp.route('/start', methods=['POST'])
|
||||||
@@ -281,6 +363,7 @@ def start_decoding() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(rtl_process)
|
||||||
|
|
||||||
# Start a thread to monitor rtl_fm stderr for errors
|
# Start a thread to monitor rtl_fm stderr for errors
|
||||||
def monitor_rtl_stderr():
|
def monitor_rtl_stderr():
|
||||||
@@ -299,18 +382,30 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_process = subprocess.Popen(
|
multimon_process = subprocess.Popen(
|
||||||
multimon_cmd,
|
multimon_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=subprocess.PIPE,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
)
|
)
|
||||||
|
register_process(multimon_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
rtl_process.stdout.close()
|
|
||||||
|
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||||||
|
stop_relay = threading.Event()
|
||||||
|
relay = threading.Thread(
|
||||||
|
target=audio_relay_thread,
|
||||||
|
args=(rtl_process.stdout, multimon_process.stdin,
|
||||||
|
app_module.output_queue, stop_relay),
|
||||||
|
)
|
||||||
|
relay.daemon = True
|
||||||
|
relay.start()
|
||||||
|
|
||||||
app_module.current_process = multimon_process
|
app_module.current_process = multimon_process
|
||||||
app_module.current_process._rtl_process = rtl_process
|
app_module.current_process._rtl_process = rtl_process
|
||||||
app_module.current_process._master_fd = master_fd
|
app_module.current_process._master_fd = master_fd
|
||||||
|
app_module.current_process._stop_relay = stop_relay
|
||||||
|
app_module.current_process._relay_thread = relay
|
||||||
|
|
||||||
# Start output thread with PTY master fd
|
# Start output thread with PTY master fd
|
||||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||||
@@ -322,12 +417,30 @@ def start_decoding() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
# Kill orphaned rtl_fm process
|
||||||
|
try:
|
||||||
|
rtl_process.terminate()
|
||||||
|
rtl_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device)
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Kill orphaned rtl_fm process if it was started
|
||||||
|
try:
|
||||||
|
rtl_process.terminate()
|
||||||
|
rtl_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device)
|
||||||
@@ -341,6 +454,10 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
|
# Signal audio relay thread to stop
|
||||||
|
if hasattr(app_module.current_process, '_stop_relay'):
|
||||||
|
app_module.current_process._stop_relay.set()
|
||||||
|
|
||||||
# Kill rtl_fm process first
|
# Kill rtl_fm process first
|
||||||
if hasattr(app_module.current_process, '_rtl_process'):
|
if hasattr(app_module.current_process, '_rtl_process'):
|
||||||
try:
|
try:
|
||||||
@@ -433,6 +550,10 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
msg = app_module.output_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('pager', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Session recording API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
|
|
||||||
|
from utils.recording import get_recording_manager, RECORDING_ROOT
|
||||||
|
|
||||||
|
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/start', methods=['POST'])
|
||||||
|
def start_recording():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
mode = (data.get('mode') or '').strip()
|
||||||
|
if not mode:
|
||||||
|
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
|
||||||
|
|
||||||
|
label = data.get('label')
|
||||||
|
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||||
|
|
||||||
|
manager = get_recording_manager()
|
||||||
|
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'session': {
|
||||||
|
'id': session.id,
|
||||||
|
'mode': session.mode,
|
||||||
|
'label': session.label,
|
||||||
|
'started_at': session.started_at.isoformat(),
|
||||||
|
'file_path': str(session.file_path),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_recording():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
mode = data.get('mode')
|
||||||
|
session_id = data.get('id')
|
||||||
|
|
||||||
|
manager = get_recording_manager()
|
||||||
|
session = manager.stop_recording(mode=mode, session_id=session_id)
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'session': {
|
||||||
|
'id': session.id,
|
||||||
|
'mode': session.mode,
|
||||||
|
'label': session.label,
|
||||||
|
'started_at': session.started_at.isoformat(),
|
||||||
|
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||||
|
'event_count': session.event_count,
|
||||||
|
'size_bytes': session.size_bytes,
|
||||||
|
'file_path': str(session.file_path),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('', methods=['GET'])
|
||||||
|
def list_recordings():
|
||||||
|
manager = get_recording_manager()
|
||||||
|
limit = request.args.get('limit', default=50, type=int)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'recordings': manager.list_recordings(limit=limit),
|
||||||
|
'active': manager.get_active(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/<session_id>', methods=['GET'])
|
||||||
|
def get_recording(session_id: str):
|
||||||
|
manager = get_recording_manager()
|
||||||
|
rec = manager.get_recording(session_id)
|
||||||
|
if not rec:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||||
|
return jsonify({'status': 'success', 'recording': rec})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
||||||
|
def download_recording(session_id: str):
|
||||||
|
manager = get_recording_manager()
|
||||||
|
rec = manager.get_recording(session_id)
|
||||||
|
if not rec:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||||
|
|
||||||
|
file_path = Path(rec['file_path'])
|
||||||
|
try:
|
||||||
|
resolved_root = RECORDING_ROOT.resolve()
|
||||||
|
resolved_file = file_path.resolve()
|
||||||
|
if resolved_root not in resolved_file.parents:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
file_path,
|
||||||
|
mimetype='application/x-ndjson',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=file_path.name,
|
||||||
|
)
|
||||||
+41
-3
@@ -18,7 +18,8 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.process import safe_terminate, register_process
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
|
||||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||||
|
|
||||||
@@ -61,10 +62,37 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
process.wait()
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
# Ensure rtlamr process is terminated
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(process)
|
||||||
|
# Kill companion rtl_tcp process
|
||||||
|
with rtl_tcp_lock:
|
||||||
|
if rtl_tcp_process:
|
||||||
|
try:
|
||||||
|
rtl_tcp_process.terminate()
|
||||||
|
rtl_tcp_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
rtl_tcp_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(rtl_tcp_process)
|
||||||
|
rtl_tcp_process = None
|
||||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
app_module.rtlamr_process = None
|
app_module.rtlamr_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||||
@@ -133,7 +161,8 @@ def start_rtlamr() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(rtl_tcp_process)
|
||||||
|
|
||||||
# Wait a moment for rtl_tcp to start
|
# Wait a moment for rtl_tcp to start
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
@@ -141,6 +170,10 @@ def start_rtlamr() -> Response:
|
|||||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||||
|
# Release SDR device on rtl_tcp failure
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||||
|
|
||||||
# Build rtlamr command
|
# Build rtlamr command
|
||||||
@@ -174,6 +207,7 @@ def start_rtlamr() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(app_module.rtlamr_process)
|
||||||
|
|
||||||
# Start output thread
|
# Start output thread
|
||||||
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
||||||
@@ -262,6 +296,10 @@ def stream_rtlamr() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('rtlamr', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+38
-31
@@ -31,6 +31,23 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
|
||||||
|
def init_tle_auto_refresh():
|
||||||
|
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def _auto_refresh_tle():
|
||||||
|
try:
|
||||||
|
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.
|
||||||
@@ -151,17 +168,13 @@ def predict_passes():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
25338: 'NOAA-15',
|
40069: 'METEOR-M2',
|
||||||
28654: 'NOAA-18',
|
57166: 'METEOR-M2-3'
|
||||||
33591: 'NOAA-19',
|
}
|
||||||
43013: 'NOAA-20',
|
|
||||||
40069: 'METEOR-M2',
|
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
||||||
57166: 'METEOR-M2-3'
|
|
||||||
}
|
|
||||||
|
|
||||||
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
|
||||||
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:
|
||||||
@@ -170,15 +183,11 @@ def predict_passes():
|
|||||||
satellites.append(sat)
|
satellites.append(sat)
|
||||||
|
|
||||||
passes = []
|
passes = []
|
||||||
colors = {
|
colors = {
|
||||||
'ISS': '#00ffff',
|
'ISS': '#00ffff',
|
||||||
'NOAA-15': '#00ff00',
|
'METEOR-M2': '#9370DB',
|
||||||
'NOAA-18': '#ff6600',
|
'METEOR-M2-3': '#ff00ff'
|
||||||
'NOAA-19': '#ff3366',
|
}
|
||||||
'NOAA-20': '#00ffaa',
|
|
||||||
'METEOR-M2': '#9370DB',
|
|
||||||
'METEOR-M2-3': '#ff00ff'
|
|
||||||
}
|
|
||||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||||
|
|
||||||
ts = load.timescale()
|
ts = load.timescale()
|
||||||
@@ -310,15 +319,11 @@ def get_satellite_position():
|
|||||||
sat_input = data.get('satellites', [])
|
sat_input = data.get('satellites', [])
|
||||||
include_track = bool(data.get('includeTrack', True))
|
include_track = bool(data.get('includeTrack', True))
|
||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
25338: 'NOAA-15',
|
40069: 'METEOR-M2',
|
||||||
28654: 'NOAA-18',
|
57166: 'METEOR-M2-3'
|
||||||
33591: 'NOAA-19',
|
}
|
||||||
43013: 'NOAA-20',
|
|
||||||
40069: 'METEOR-M2',
|
|
||||||
57166: 'METEOR-M2-3'
|
|
||||||
}
|
|
||||||
|
|
||||||
satellites = []
|
satellites = []
|
||||||
for sat in sat_input:
|
for sat in sat_input:
|
||||||
@@ -481,7 +486,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 +541,5 @@ 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'})
|
||||||
|
|||||||
+49
-2
@@ -19,7 +19,8 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.process import safe_terminate, register_process
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
sensor_bp = Blueprint('sensor', __name__)
|
sensor_bp = Blueprint('sensor', __name__)
|
||||||
@@ -44,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
|
# Push scope event when signal level data is present
|
||||||
|
rssi = data.get('rssi')
|
||||||
|
snr = data.get('snr')
|
||||||
|
noise = data.get('noise')
|
||||||
|
if rssi is not None or snr is not None:
|
||||||
|
try:
|
||||||
|
app_module.sensor_queue.put_nowait({
|
||||||
|
'type': 'scope',
|
||||||
|
'rssi': rssi if rssi is not None else 0,
|
||||||
|
'snr': snr if snr is not None else 0,
|
||||||
|
'noise': noise if noise is not None else 0,
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -59,10 +75,32 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
process.wait()
|
global sensor_active_device
|
||||||
|
# Ensure process is terminated
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(process)
|
||||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
|
|
||||||
|
|
||||||
|
@sensor_bp.route('/sensor/status')
|
||||||
|
def sensor_status() -> Response:
|
||||||
|
"""Check if sensor decoder is currently running."""
|
||||||
|
with app_module.sensor_lock:
|
||||||
|
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||||
|
return jsonify({'running': running})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
@@ -143,12 +181,17 @@ def start_sensor() -> Response:
|
|||||||
full_cmd = ' '.join(cmd)
|
full_cmd = ' '.join(cmd)
|
||||||
logger.info(f"Running: {full_cmd}")
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
|
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||||
|
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||||
|
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.sensor_process = subprocess.Popen(
|
app_module.sensor_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(app_module.sensor_process)
|
||||||
|
|
||||||
# Start output thread
|
# Start output thread
|
||||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||||
@@ -218,6 +261,10 @@ def stream_sensor() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
msg = app_module.sensor_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sensor', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+103
-7
@@ -13,14 +13,14 @@ from typing import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
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.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
is_sstv_available,
|
is_sstv_available,
|
||||||
ISS_SSTV_FREQ,
|
ISS_SSTV_FREQ,
|
||||||
DecodeProgress,
|
|
||||||
DopplerInfo,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.sstv')
|
logger = get_logger('intercept.sstv')
|
||||||
@@ -30,15 +30,18 @@ sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
|||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
sstv_active_device: int | None = None
|
||||||
|
|
||||||
def _progress_callback(progress: DecodeProgress) -> None:
|
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
def _progress_callback(data: dict) -> None:
|
||||||
|
"""Callback to queue progress/scope updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_queue.put_nowait(progress.to_dict())
|
_sstv_queue.put_nowait(data)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_queue.get_nowait()
|
_sstv_queue.get_nowait()
|
||||||
_sstv_queue.put_nowait(progress.to_dict())
|
_sstv_queue.put_nowait(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ def start_decoder():
|
|||||||
if not is_sstv_available():
|
if not is_sstv_available():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
|
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
decoder = get_sstv_decoder()
|
decoder = get_sstv_decoder()
|
||||||
@@ -158,6 +161,17 @@ def start_decoder():
|
|||||||
latitude = None
|
latitude = None
|
||||||
longitude = None
|
longitude = None
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
global sstv_active_device
|
||||||
|
device_int = int(device_index)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'sstv')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Set callback and start
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
success = decoder.start(
|
success = decoder.start(
|
||||||
@@ -168,6 +182,8 @@ def start_decoder():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
sstv_active_device = device_int
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
@@ -181,6 +197,8 @@ def start_decoder():
|
|||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
else:
|
else:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder'
|
'message': 'Failed to start decoder'
|
||||||
@@ -195,8 +213,15 @@ def stop_decoder():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON confirmation.
|
JSON confirmation.
|
||||||
"""
|
"""
|
||||||
|
global sstv_active_device
|
||||||
decoder = get_sstv_decoder()
|
decoder = get_sstv_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if sstv_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sstv_active_device)
|
||||||
|
sstv_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -287,6 +312,73 @@ def get_image(filename: str):
|
|||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/images/<filename>/download')
|
||||||
|
def download_image(filename: str):
|
||||||
|
"""
|
||||||
|
Download a decoded SSTV image file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image file as attachment or 404.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not filename.endswith('.png'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
|
||||||
|
def delete_image(filename: str):
|
||||||
|
"""
|
||||||
|
Delete a decoded SSTV image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not filename.endswith('.png'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
|
if decoder.delete_image(filename):
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/images', methods=['DELETE'])
|
||||||
|
def delete_all_images():
|
||||||
|
"""
|
||||||
|
Delete all decoded SSTV images.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with count of deleted images.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
count = decoder.delete_all_images()
|
||||||
|
return jsonify({'status': 'ok', 'deleted': count})
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/stream')
|
@sstv_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""
|
"""
|
||||||
@@ -308,6 +400,10 @@ def stream_progress():
|
|||||||
try:
|
try:
|
||||||
progress = _sstv_queue.get(timeout=1)
|
progress = _sstv_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sstv', progress, progress.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+56
-6
@@ -15,8 +15,8 @@ from flask import Blueprint, Response, jsonify, request, send_file
|
|||||||
|
|
||||||
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.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
DecodeProgress,
|
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,14 +48,14 @@ SSTV_FREQUENCIES = [
|
|||||||
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(progress: DecodeProgress) -> None:
|
def _progress_callback(data: dict) -> None:
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
"""Callback to queue progress/scope updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.put_nowait(progress.to_dict())
|
_sstv_general_queue.put_nowait(data)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.get_nowait()
|
_sstv_general_queue.get_nowait()
|
||||||
_sstv_general_queue.put_nowait(progress.to_dict())
|
_sstv_general_queue.put_nowait(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ def start_decoder():
|
|||||||
if decoder.decoder_available is None:
|
if decoder.decoder_available is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx',
|
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
if decoder.is_running:
|
if decoder.is_running:
|
||||||
@@ -217,6 +217,52 @@ def get_image(filename: str):
|
|||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_general_bp.route('/images/<filename>/download')
|
||||||
|
def download_image(filename: str):
|
||||||
|
"""Download a decoded SSTV image file."""
|
||||||
|
decoder = get_general_sstv_decoder()
|
||||||
|
|
||||||
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not filename.endswith('.png'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_general_bp.route('/images/<filename>', methods=['DELETE'])
|
||||||
|
def delete_image(filename: str):
|
||||||
|
"""Delete a decoded SSTV image."""
|
||||||
|
decoder = get_general_sstv_decoder()
|
||||||
|
|
||||||
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not filename.endswith('.png'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
|
if decoder.delete_image(filename):
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_general_bp.route('/images', methods=['DELETE'])
|
||||||
|
def delete_all_images():
|
||||||
|
"""Delete all decoded SSTV images."""
|
||||||
|
decoder = get_general_sstv_decoder()
|
||||||
|
count = decoder.delete_all_images()
|
||||||
|
return jsonify({'status': 'ok', 'deleted': count})
|
||||||
|
|
||||||
|
|
||||||
@sstv_general_bp.route('/stream')
|
@sstv_general_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""SSE stream of SSTV decode progress."""
|
"""SSE stream of SSTV decode progress."""
|
||||||
@@ -228,6 +274,10 @@ def stream_progress():
|
|||||||
try:
|
try:
|
||||||
progress = _sstv_general_queue.get(timeout=1)
|
progress = _sstv_general_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sstv_general', progress, progress.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -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
|
||||||
+150
-1
@@ -60,6 +60,7 @@ from utils.tscm.device_identity import (
|
|||||||
ingest_ble_dict,
|
ingest_ble_dict,
|
||||||
ingest_wifi_dict,
|
ingest_wifi_dict,
|
||||||
)
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
# Import unified Bluetooth scanner helper for TSCM integration
|
# Import unified Bluetooth scanner helper for TSCM integration
|
||||||
try:
|
try:
|
||||||
@@ -550,6 +551,12 @@ def _start_sweep_internal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tscm_bp.route('/status')
|
||||||
|
def tscm_status():
|
||||||
|
"""Check if any TSCM operation is currently running."""
|
||||||
|
return jsonify({'running': _sweep_running})
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/start', methods=['POST'])
|
@tscm_bp.route('/sweep/start', methods=['POST'])
|
||||||
def start_sweep():
|
def start_sweep():
|
||||||
"""Start a TSCM sweep."""
|
"""Start a TSCM sweep."""
|
||||||
@@ -627,6 +634,10 @@ def sweep_stream():
|
|||||||
try:
|
try:
|
||||||
if tscm_queue:
|
if tscm_queue:
|
||||||
msg = tscm_queue.get(timeout=1)
|
msg = tscm_queue.get(timeout=1)
|
||||||
|
try:
|
||||||
|
process_event('tscm', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield f"data: {json.dumps(msg)}\n\n"
|
yield f"data: {json.dumps(msg)}\n\n"
|
||||||
else:
|
else:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -1072,6 +1083,32 @@ def _scan_wifi_networks(interface: str) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_wifi_clients(interface: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get WiFi client observations from the unified WiFi scanner.
|
||||||
|
|
||||||
|
Clients are only available when monitor-mode scanning is active.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from utils.wifi import get_wifi_scanner
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
if interface:
|
||||||
|
try:
|
||||||
|
if not scanner._is_monitor_mode_interface(interface):
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [client.to_dict() for client in scanner.clients]
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(f"Failed to import wifi scanner: {e}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"WiFi client scan failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Scan for Bluetooth devices with manufacturer data detection.
|
Scan for Bluetooth devices with manufacturer data detection.
|
||||||
@@ -1606,6 +1643,7 @@ def _run_sweep(
|
|||||||
threats_found = 0
|
threats_found = 0
|
||||||
severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
|
severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
|
||||||
all_wifi = {} # Use dict for deduplication by BSSID
|
all_wifi = {} # Use dict for deduplication by BSSID
|
||||||
|
all_wifi_clients = {} # Use dict for deduplication by client MAC
|
||||||
all_bt = {} # Use dict for deduplication by MAC
|
all_bt = {} # Use dict for deduplication by MAC
|
||||||
all_rf = []
|
all_rf = []
|
||||||
|
|
||||||
@@ -1702,6 +1740,7 @@ def _run_sweep(
|
|||||||
'channel': network.get('channel', ''),
|
'channel': network.get('channel', ''),
|
||||||
'signal': network.get('power', ''),
|
'signal': network.get('power', ''),
|
||||||
'security': network.get('privacy', ''),
|
'security': network.get('privacy', ''),
|
||||||
|
'vendor': network.get('vendor'),
|
||||||
'is_threat': is_threat,
|
'is_threat': is_threat,
|
||||||
'is_new': not classification.get('in_baseline', False),
|
'is_new': not classification.get('in_baseline', False),
|
||||||
'classification': profile.risk_level.value,
|
'classification': profile.risk_level.value,
|
||||||
@@ -1715,6 +1754,77 @@ def _run_sweep(
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}")
|
logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}")
|
||||||
|
|
||||||
|
# WiFi clients (monitor mode only)
|
||||||
|
try:
|
||||||
|
wifi_clients = _scan_wifi_clients(wifi_interface)
|
||||||
|
for client in wifi_clients:
|
||||||
|
mac = (client.get('mac') or '').upper()
|
||||||
|
if not mac or mac in all_wifi_clients:
|
||||||
|
continue
|
||||||
|
all_wifi_clients[mac] = client
|
||||||
|
|
||||||
|
rssi_val = client.get('rssi_current')
|
||||||
|
if rssi_val is None:
|
||||||
|
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
||||||
|
|
||||||
|
client_device = {
|
||||||
|
'mac': mac,
|
||||||
|
'vendor': client.get('vendor'),
|
||||||
|
'name': client.get('vendor') or 'WiFi Client',
|
||||||
|
'rssi': rssi_val,
|
||||||
|
'associated_bssid': client.get('associated_bssid'),
|
||||||
|
'probed_ssids': client.get('probed_ssids', []),
|
||||||
|
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||||
|
'is_client': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeline_manager.add_observation(
|
||||||
|
identifier=mac,
|
||||||
|
protocol='wifi',
|
||||||
|
rssi=rssi_val,
|
||||||
|
name=client_device.get('vendor') or f'WiFi Client {mac[-5:]}',
|
||||||
|
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WiFi client timeline observation error: {e}")
|
||||||
|
_maybe_store_timeline(
|
||||||
|
identifier=mac,
|
||||||
|
protocol='wifi',
|
||||||
|
rssi=rssi_val,
|
||||||
|
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = correlation.analyze_wifi_device(client_device)
|
||||||
|
client_device['classification'] = profile.risk_level.value
|
||||||
|
client_device['score'] = profile.total_score
|
||||||
|
client_device['score_modifier'] = profile.score_modifier
|
||||||
|
client_device['known_device'] = profile.known_device
|
||||||
|
client_device['known_device_name'] = profile.known_device_name
|
||||||
|
client_device['indicators'] = [
|
||||||
|
{'type': i.type.value, 'desc': i.description}
|
||||||
|
for i in profile.indicators
|
||||||
|
]
|
||||||
|
client_device['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
|
# Feed to identity engine for MAC-randomization resistant clustering
|
||||||
|
try:
|
||||||
|
wifi_obs = {
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'src_mac': mac,
|
||||||
|
'bssid': client_device.get('associated_bssid'),
|
||||||
|
'rssi': rssi_val,
|
||||||
|
'frame_type': 'probe_request',
|
||||||
|
'probed_ssids': client_device.get('probed_ssids', []),
|
||||||
|
}
|
||||||
|
ingest_wifi_dict(wifi_obs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Identity engine WiFi client ingest error: {e}")
|
||||||
|
|
||||||
|
_emit_event('wifi_client', client_device)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WiFi client scan error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_wifi_scan = current_time
|
last_wifi_scan = current_time
|
||||||
logger.error(f"WiFi scan error: {e}")
|
logger.error(f"WiFi scan error: {e}")
|
||||||
@@ -1793,6 +1903,9 @@ def _run_sweep(
|
|||||||
'name': device.get('name', 'Unknown'),
|
'name': device.get('name', 'Unknown'),
|
||||||
'device_type': device.get('type', ''),
|
'device_type': device.get('type', ''),
|
||||||
'rssi': device.get('rssi', ''),
|
'rssi': device.get('rssi', ''),
|
||||||
|
'manufacturer': device.get('manufacturer'),
|
||||||
|
'tracker': device.get('tracker'),
|
||||||
|
'tracker_type': device.get('tracker_type'),
|
||||||
'is_threat': is_threat,
|
'is_threat': is_threat,
|
||||||
'is_new': not classification.get('in_baseline', False),
|
'is_new': not classification.get('in_baseline', False),
|
||||||
'classification': profile.risk_level.value,
|
'classification': profile.risk_level.value,
|
||||||
@@ -1921,6 +2034,7 @@ def _run_sweep(
|
|||||||
comparator = BaselineComparator(baseline)
|
comparator = BaselineComparator(baseline)
|
||||||
baseline_comparison = comparator.compare_all(
|
baseline_comparison = comparator.compare_all(
|
||||||
wifi_devices=list(all_wifi.values()),
|
wifi_devices=list(all_wifi.values()),
|
||||||
|
wifi_clients=list(all_wifi_clients.values()),
|
||||||
bt_devices=list(all_bt.values()),
|
bt_devices=list(all_bt.values()),
|
||||||
rf_signals=all_rf
|
rf_signals=all_rf
|
||||||
)
|
)
|
||||||
@@ -1936,6 +2050,7 @@ def _run_sweep(
|
|||||||
|
|
||||||
if verbose_results:
|
if verbose_results:
|
||||||
wifi_payload = list(all_wifi.values())
|
wifi_payload = list(all_wifi.values())
|
||||||
|
wifi_client_payload = list(all_wifi_clients.values())
|
||||||
bt_payload = list(all_bt.values())
|
bt_payload = list(all_bt.values())
|
||||||
rf_payload = list(all_rf)
|
rf_payload = list(all_rf)
|
||||||
else:
|
else:
|
||||||
@@ -1951,6 +2066,28 @@ def _run_sweep(
|
|||||||
}
|
}
|
||||||
for d in all_wifi.values()
|
for d in all_wifi.values()
|
||||||
]
|
]
|
||||||
|
wifi_client_payload = []
|
||||||
|
for client in all_wifi_clients.values():
|
||||||
|
mac = client.get('mac') or client.get('address')
|
||||||
|
if isinstance(mac, str):
|
||||||
|
mac = mac.upper()
|
||||||
|
probed_ssids = client.get('probed_ssids') or []
|
||||||
|
rssi = client.get('rssi')
|
||||||
|
if rssi is None:
|
||||||
|
rssi = client.get('rssi_current')
|
||||||
|
if rssi is None:
|
||||||
|
rssi = client.get('rssi_median')
|
||||||
|
if rssi is None:
|
||||||
|
rssi = client.get('rssi_ema')
|
||||||
|
wifi_client_payload.append({
|
||||||
|
'mac': mac,
|
||||||
|
'vendor': client.get('vendor'),
|
||||||
|
'rssi': rssi,
|
||||||
|
'associated_bssid': client.get('associated_bssid'),
|
||||||
|
'is_associated': client.get('is_associated'),
|
||||||
|
'probed_ssids': probed_ssids,
|
||||||
|
'probe_count': client.get('probe_count', len(probed_ssids)),
|
||||||
|
})
|
||||||
bt_payload = [
|
bt_payload = [
|
||||||
{
|
{
|
||||||
'mac': d.get('mac') or d.get('address'),
|
'mac': d.get('mac') or d.get('address'),
|
||||||
@@ -1975,9 +2112,11 @@ def _run_sweep(
|
|||||||
status='completed',
|
status='completed',
|
||||||
results={
|
results={
|
||||||
'wifi_devices': wifi_payload,
|
'wifi_devices': wifi_payload,
|
||||||
|
'wifi_clients': wifi_client_payload,
|
||||||
'bt_devices': bt_payload,
|
'bt_devices': bt_payload,
|
||||||
'rf_signals': rf_payload,
|
'rf_signals': rf_payload,
|
||||||
'wifi_count': len(all_wifi),
|
'wifi_count': len(all_wifi),
|
||||||
|
'wifi_client_count': len(all_wifi_clients),
|
||||||
'bt_count': len(all_bt),
|
'bt_count': len(all_bt),
|
||||||
'rf_count': len(all_rf),
|
'rf_count': len(all_rf),
|
||||||
'severity_counts': severity_counts,
|
'severity_counts': severity_counts,
|
||||||
@@ -2005,6 +2144,7 @@ def _run_sweep(
|
|||||||
'total_new': baseline_comparison['total_new'],
|
'total_new': baseline_comparison['total_new'],
|
||||||
'total_missing': baseline_comparison['total_missing'],
|
'total_missing': baseline_comparison['total_missing'],
|
||||||
'wifi': baseline_comparison.get('wifi'),
|
'wifi': baseline_comparison.get('wifi'),
|
||||||
|
'wifi_clients': baseline_comparison.get('wifi_clients'),
|
||||||
'bluetooth': baseline_comparison.get('bluetooth'),
|
'bluetooth': baseline_comparison.get('bluetooth'),
|
||||||
'rf': baseline_comparison.get('rf'),
|
'rf': baseline_comparison.get('rf'),
|
||||||
})
|
})
|
||||||
@@ -2022,6 +2162,7 @@ def _run_sweep(
|
|||||||
'sweep_id': _current_sweep_id,
|
'sweep_id': _current_sweep_id,
|
||||||
'threats_found': threats_found,
|
'threats_found': threats_found,
|
||||||
'wifi_count': len(all_wifi),
|
'wifi_count': len(all_wifi),
|
||||||
|
'wifi_client_count': len(all_wifi_clients),
|
||||||
'bt_count': len(all_bt),
|
'bt_count': len(all_bt),
|
||||||
'rf_count': len(all_rf),
|
'rf_count': len(all_rf),
|
||||||
'severity_counts': severity_counts,
|
'severity_counts': severity_counts,
|
||||||
@@ -2169,6 +2310,7 @@ def compare_against_baseline():
|
|||||||
|
|
||||||
Expects JSON body with:
|
Expects JSON body with:
|
||||||
- wifi_devices: list of WiFi devices (optional)
|
- wifi_devices: list of WiFi devices (optional)
|
||||||
|
- wifi_clients: list of WiFi clients (optional)
|
||||||
- bt_devices: list of Bluetooth devices (optional)
|
- bt_devices: list of Bluetooth devices (optional)
|
||||||
- rf_signals: list of RF signals (optional)
|
- rf_signals: list of RF signals (optional)
|
||||||
|
|
||||||
@@ -2177,12 +2319,14 @@ def compare_against_baseline():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
wifi_devices = data.get('wifi_devices')
|
wifi_devices = data.get('wifi_devices')
|
||||||
|
wifi_clients = data.get('wifi_clients')
|
||||||
bt_devices = data.get('bt_devices')
|
bt_devices = data.get('bt_devices')
|
||||||
rf_signals = data.get('rf_signals')
|
rf_signals = data.get('rf_signals')
|
||||||
|
|
||||||
# Use the convenience function that gets active baseline
|
# Use the convenience function that gets active baseline
|
||||||
comparison = get_comparison_for_active_baseline(
|
comparison = get_comparison_for_active_baseline(
|
||||||
wifi_devices=wifi_devices,
|
wifi_devices=wifi_devices,
|
||||||
|
wifi_clients=wifi_clients,
|
||||||
bt_devices=bt_devices,
|
bt_devices=bt_devices,
|
||||||
rf_signals=rf_signals
|
rf_signals=rf_signals
|
||||||
)
|
)
|
||||||
@@ -2276,7 +2420,10 @@ def feed_wifi():
|
|||||||
"""Feed WiFi device data for baseline recording."""
|
"""Feed WiFi device data for baseline recording."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if data:
|
if data:
|
||||||
_baseline_recorder.add_wifi_device(data)
|
if data.get('is_client'):
|
||||||
|
_baseline_recorder.add_wifi_client(data)
|
||||||
|
else:
|
||||||
|
_baseline_recorder.add_wifi_device(data)
|
||||||
return jsonify({'status': 'success'})
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
|
||||||
@@ -2928,12 +3075,14 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
|
|||||||
results = json.loads(results)
|
results = json.loads(results)
|
||||||
|
|
||||||
current_wifi = results.get('wifi_devices', [])
|
current_wifi = results.get('wifi_devices', [])
|
||||||
|
current_wifi_clients = results.get('wifi_clients', [])
|
||||||
current_bt = results.get('bt_devices', [])
|
current_bt = results.get('bt_devices', [])
|
||||||
current_rf = results.get('rf_signals', [])
|
current_rf = results.get('rf_signals', [])
|
||||||
|
|
||||||
diff = calculate_baseline_diff(
|
diff = calculate_baseline_diff(
|
||||||
baseline=baseline,
|
baseline=baseline,
|
||||||
current_wifi=current_wifi,
|
current_wifi=current_wifi,
|
||||||
|
current_wifi_clients=current_wifi_clients,
|
||||||
current_bt=current_bt,
|
current_bt=current_bt,
|
||||||
current_rf=current_rf,
|
current_rf=current_rf,
|
||||||
sweep_id=sweep_id
|
sweep_id=sweep_id
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_sock import Sock
|
||||||
|
WEBSOCKET_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
WEBSOCKET_AVAILABLE = False
|
||||||
|
Sock = None
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
from utils.waterfall_fft import (
|
||||||
|
build_binary_frame,
|
||||||
|
compute_power_spectrum,
|
||||||
|
cu8_to_complex,
|
||||||
|
quantize_to_uint8,
|
||||||
|
)
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||||
|
|
||||||
|
logger = get_logger('intercept.waterfall_ws')
|
||||||
|
|
||||||
|
# Maximum bandwidth per SDR type (Hz)
|
||||||
|
MAX_BANDWIDTH = {
|
||||||
|
SDRType.RTL_SDR: 2400000,
|
||||||
|
SDRType.HACKRF: 20000000,
|
||||||
|
SDRType.LIME_SDR: 20000000,
|
||||||
|
SDRType.AIRSPY: 10000000,
|
||||||
|
SDRType.SDRPLAY: 2000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||||
|
"""Convert client sdr_type string to SDRType enum."""
|
||||||
|
mapping = {
|
||||||
|
'rtlsdr': SDRType.RTL_SDR,
|
||||||
|
'rtl_sdr': SDRType.RTL_SDR,
|
||||||
|
'hackrf': SDRType.HACKRF,
|
||||||
|
'limesdr': SDRType.LIME_SDR,
|
||||||
|
'lime_sdr': SDRType.LIME_SDR,
|
||||||
|
'airspy': SDRType.AIRSPY,
|
||||||
|
'sdrplay': SDRType.SDRPLAY,
|
||||||
|
}
|
||||||
|
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
||||||
|
"""Build a minimal SDRDevice for command building."""
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
caps = builder.get_capabilities()
|
||||||
|
return SDRDevice(
|
||||||
|
sdr_type=sdr_type,
|
||||||
|
index=device_index,
|
||||||
|
name=f'{sdr_type.value}-{device_index}',
|
||||||
|
serial='N/A',
|
||||||
|
driver=sdr_type.value,
|
||||||
|
capabilities=caps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_waterfall_websocket(app: Flask):
|
||||||
|
"""Initialize WebSocket waterfall streaming."""
|
||||||
|
if not WEBSOCKET_AVAILABLE:
|
||||||
|
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
sock = Sock(app)
|
||||||
|
|
||||||
|
@sock.route('/ws/waterfall')
|
||||||
|
def waterfall_stream(ws):
|
||||||
|
"""WebSocket endpoint for real-time waterfall streaming."""
|
||||||
|
logger.info("WebSocket waterfall client connected")
|
||||||
|
|
||||||
|
# Import app module for device claiming
|
||||||
|
import app as app_module
|
||||||
|
|
||||||
|
iq_process = None
|
||||||
|
reader_thread = None
|
||||||
|
stop_event = threading.Event()
|
||||||
|
claimed_device = None
|
||||||
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Drain send queue first (non-blocking)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
outgoing = send_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
ws.send(outgoing)
|
||||||
|
except Exception:
|
||||||
|
stop_event.set()
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = ws.receive(timeout=0.1)
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e).lower()
|
||||||
|
if "closed" in err:
|
||||||
|
break
|
||||||
|
if "timed out" not in err:
|
||||||
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg is None:
|
||||||
|
# simple-websocket returns None on timeout AND on
|
||||||
|
# close; check ws.connected to tell them apart.
|
||||||
|
if not ws.connected:
|
||||||
|
break
|
||||||
|
if stop_event.is_set():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(msg)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cmd = data.get('cmd')
|
||||||
|
|
||||||
|
if cmd == 'start':
|
||||||
|
# Stop any existing capture
|
||||||
|
was_restarting = iq_process is not None
|
||||||
|
stop_event.set()
|
||||||
|
if reader_thread and reader_thread.is_alive():
|
||||||
|
reader_thread.join(timeout=2)
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
if claimed_device is not None:
|
||||||
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
claimed_device = None
|
||||||
|
stop_event.clear()
|
||||||
|
# Flush stale frames from previous capture
|
||||||
|
while not send_queue.empty():
|
||||||
|
try:
|
||||||
|
send_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
# Allow USB device to be released by the kernel
|
||||||
|
if was_restarting:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Parse config
|
||||||
|
center_freq = float(data.get('center_freq', 100.0))
|
||||||
|
span_mhz = float(data.get('span_mhz', 2.0))
|
||||||
|
gain = data.get('gain')
|
||||||
|
if gain is not None:
|
||||||
|
gain = float(gain)
|
||||||
|
device_index = int(data.get('device', 0))
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
fft_size = int(data.get('fft_size', 1024))
|
||||||
|
fps = int(data.get('fps', 25))
|
||||||
|
avg_count = int(data.get('avg_count', 4))
|
||||||
|
ppm = data.get('ppm')
|
||||||
|
if ppm is not None:
|
||||||
|
ppm = int(ppm)
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
|
||||||
|
# Clamp FFT size to valid powers of 2
|
||||||
|
fft_size = max(256, min(8192, fft_size))
|
||||||
|
|
||||||
|
# Resolve SDR type and bandwidth
|
||||||
|
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||||
|
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||||
|
span_hz = int(span_mhz * 1e6)
|
||||||
|
sample_rate = min(span_hz, max_bw)
|
||||||
|
|
||||||
|
# Compute effective frequency range
|
||||||
|
effective_span_mhz = sample_rate / 1e6
|
||||||
|
start_freq = center_freq - effective_span_mhz / 2
|
||||||
|
end_freq = center_freq + effective_span_mhz / 2
|
||||||
|
|
||||||
|
# Claim the device
|
||||||
|
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||||
|
if claim_err:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': claim_err,
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
claimed_device = device_index
|
||||||
|
|
||||||
|
# Build I/Q capture command
|
||||||
|
try:
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device = _build_dummy_device(device_index, sdr_type)
|
||||||
|
iq_cmd = builder.build_iq_capture_command(
|
||||||
|
device=device,
|
||||||
|
frequency_mhz=center_freq,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
gain=gain,
|
||||||
|
ppm=ppm,
|
||||||
|
bias_t=bias_t,
|
||||||
|
)
|
||||||
|
except NotImplementedError as e:
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
claimed_device = None
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||||
|
max_attempts = 3 if was_restarting else 1
|
||||||
|
try:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
logger.info(
|
||||||
|
f"Starting I/Q capture: {center_freq} MHz, "
|
||||||
|
f"span={effective_span_mhz:.1f} MHz, "
|
||||||
|
f"sr={sample_rate}, fft={fft_size}"
|
||||||
|
)
|
||||||
|
iq_process = subprocess.Popen(
|
||||||
|
iq_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
register_process(iq_process)
|
||||||
|
|
||||||
|
# Brief check that process started
|
||||||
|
time.sleep(0.3)
|
||||||
|
if iq_process.poll() is not None:
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
logger.info(
|
||||||
|
f"I/Q process exited immediately, "
|
||||||
|
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
raise RuntimeError(
|
||||||
|
"I/Q capture process exited immediately"
|
||||||
|
)
|
||||||
|
break # Process started successfully
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start I/Q capture: {e}")
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
claimed_device = None
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to start I/Q capture: {e}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Send started confirmation
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'started',
|
||||||
|
'start_freq': start_freq,
|
||||||
|
'end_freq': end_freq,
|
||||||
|
'fft_size': fft_size,
|
||||||
|
'sample_rate': sample_rate,
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
|
def fft_reader(
|
||||||
|
proc, _send_q, stop_evt,
|
||||||
|
_fft_size, _avg_count, _fps,
|
||||||
|
_start_freq, _end_freq,
|
||||||
|
):
|
||||||
|
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||||
|
bytes_per_frame = _fft_size * _avg_count * 2
|
||||||
|
frame_interval = 1.0 / _fps
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_evt.is_set():
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
frame_start = time.monotonic()
|
||||||
|
|
||||||
|
# Read raw I/Q bytes
|
||||||
|
raw = b''
|
||||||
|
remaining = bytes_per_frame
|
||||||
|
while remaining > 0 and not stop_evt.is_set():
|
||||||
|
chunk = proc.stdout.read(min(remaining, 65536))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
raw += chunk
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
if len(raw) < _fft_size * 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Process FFT pipeline
|
||||||
|
samples = cu8_to_complex(raw)
|
||||||
|
power_db = compute_power_spectrum(
|
||||||
|
samples,
|
||||||
|
fft_size=_fft_size,
|
||||||
|
avg_count=_avg_count,
|
||||||
|
)
|
||||||
|
quantized = quantize_to_uint8(power_db)
|
||||||
|
frame = build_binary_frame(
|
||||||
|
_start_freq, _end_freq, quantized,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_send_q.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
# Drop frame if main loop can't keep up
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Pace to target FPS
|
||||||
|
elapsed = time.monotonic() - frame_start
|
||||||
|
sleep_time = frame_interval - elapsed
|
||||||
|
if sleep_time > 0:
|
||||||
|
stop_evt.wait(sleep_time)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"FFT reader stopped: {e}")
|
||||||
|
|
||||||
|
reader_thread = threading.Thread(
|
||||||
|
target=fft_reader,
|
||||||
|
args=(
|
||||||
|
iq_process, send_queue, stop_event,
|
||||||
|
fft_size, avg_count, fps,
|
||||||
|
start_freq, end_freq,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
reader_thread.start()
|
||||||
|
|
||||||
|
elif cmd == 'stop':
|
||||||
|
stop_event.set()
|
||||||
|
if reader_thread and reader_thread.is_alive():
|
||||||
|
reader_thread.join(timeout=2)
|
||||||
|
reader_thread = None
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
if claimed_device is not None:
|
||||||
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
claimed_device = None
|
||||||
|
stop_event.clear()
|
||||||
|
ws.send(json.dumps({'status': 'stopped'}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"WebSocket waterfall closed: {e}")
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
stop_event.set()
|
||||||
|
if reader_thread and reader_thread.is_alive():
|
||||||
|
reader_thread.join(timeout=2)
|
||||||
|
if iq_process:
|
||||||
|
safe_terminate(iq_process)
|
||||||
|
unregister_process(iq_process)
|
||||||
|
if claimed_device is not None:
|
||||||
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream (which browsers see as
|
||||||
|
# "Invalid frame header").
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("WebSocket waterfall client disconnected")
|
||||||
@@ -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
|
||||||
+97
-36
@@ -17,11 +17,12 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool, get_tool_path
|
from utils.dependencies import check_tool, get_tool_path
|
||||||
from utils.logging import wifi_logger as logger
|
from utils.logging import wifi_logger as logger
|
||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from data.oui import get_manufacturer
|
from utils.event_pipeline import process_event
|
||||||
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
WIFI_TERMINATE_TIMEOUT,
|
WIFI_TERMINATE_TIMEOUT,
|
||||||
PMKID_TERMINATE_TIMEOUT,
|
PMKID_TERMINATE_TIMEOUT,
|
||||||
@@ -46,8 +47,33 @@ from utils.constants import (
|
|||||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||||
|
|
||||||
# PMKID process state
|
# PMKID process state
|
||||||
pmkid_process = None
|
pmkid_process = None
|
||||||
pmkid_lock = threading.Lock()
|
pmkid_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||||
|
"""Parse a channel list from string/list input."""
|
||||||
|
if raw_channels in (None, '', []):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(raw_channels, str):
|
||||||
|
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||||
|
elif isinstance(raw_channels, (list, tuple, set)):
|
||||||
|
parts = list(raw_channels)
|
||||||
|
else:
|
||||||
|
parts = [raw_channels]
|
||||||
|
|
||||||
|
channels: list[int] = []
|
||||||
|
seen = set()
|
||||||
|
for part in parts:
|
||||||
|
if part in (None, ''):
|
||||||
|
continue
|
||||||
|
ch = validate_wifi_channel(part)
|
||||||
|
if ch not in seen:
|
||||||
|
channels.append(ch)
|
||||||
|
seen.add(ch)
|
||||||
|
|
||||||
|
return channels or None
|
||||||
|
|
||||||
|
|
||||||
def detect_wifi_interfaces():
|
def detect_wifi_interfaces():
|
||||||
@@ -607,8 +633,9 @@ def start_wifi_scan():
|
|||||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
band = data.get('band', 'abg')
|
channels = data.get('channels')
|
||||||
|
band = data.get('band', 'abg')
|
||||||
|
|
||||||
# Use provided interface or fall back to stored monitor interface
|
# Use provided interface or fall back to stored monitor interface
|
||||||
interface = data.get('interface')
|
interface = data.get('interface')
|
||||||
@@ -658,8 +685,17 @@ def start_wifi_scan():
|
|||||||
interface
|
interface
|
||||||
]
|
]
|
||||||
|
|
||||||
if channel:
|
channel_list = None
|
||||||
cmd.extend(['-c', str(channel)])
|
if channels:
|
||||||
|
try:
|
||||||
|
channel_list = _parse_channel_list(channels)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
if channel_list:
|
||||||
|
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||||
|
elif channel:
|
||||||
|
cmd.extend(['-c', str(channel)])
|
||||||
|
|
||||||
logger.info(f"Running: {' '.join(cmd)}")
|
logger.info(f"Running: {' '.join(cmd)}")
|
||||||
|
|
||||||
@@ -851,32 +887,53 @@ def check_handshake_status():
|
|||||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||||
|
|
||||||
file_size = os.path.getsize(capture_file)
|
file_size = os.path.getsize(capture_file)
|
||||||
handshake_found = False
|
handshake_found = False
|
||||||
|
handshake_valid: bool | None = None
|
||||||
|
handshake_checked = False
|
||||||
|
handshake_reason: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_bssid and is_valid_mac(target_bssid):
|
if target_bssid and is_valid_mac(target_bssid):
|
||||||
aircrack_path = get_tool_path('aircrack-ng')
|
aircrack_path = get_tool_path('aircrack-ng')
|
||||||
if aircrack_path:
|
if aircrack_path:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=10
|
||||||
)
|
)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
output_lower = output.lower()
|
||||||
if '0 handshake' not in output:
|
handshake_checked = True
|
||||||
handshake_found = True
|
|
||||||
|
if 'no valid wpa handshakes found' in output_lower:
|
||||||
|
handshake_valid = False
|
||||||
|
handshake_reason = 'No valid WPA handshake found'
|
||||||
|
elif '0 handshake' in output_lower:
|
||||||
|
handshake_valid = False
|
||||||
|
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||||
|
handshake_valid = True
|
||||||
|
else:
|
||||||
|
handshake_valid = False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking handshake: {e}")
|
logger.error(f"Error checking handshake: {e}")
|
||||||
|
|
||||||
return jsonify({
|
if handshake_valid:
|
||||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
handshake_found = True
|
||||||
'file_exists': True,
|
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||||
'file_size': file_size,
|
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||||
'file': capture_file,
|
app_module.wifi_handshakes.append(normalized_bssid)
|
||||||
'handshake_found': handshake_found
|
|
||||||
})
|
return jsonify({
|
||||||
|
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||||
|
'file_exists': True,
|
||||||
|
'file_size': file_size,
|
||||||
|
'file': capture_file,
|
||||||
|
'handshake_found': handshake_found,
|
||||||
|
'handshake_valid': handshake_valid,
|
||||||
|
'handshake_checked': handshake_checked,
|
||||||
|
'handshake_reason': handshake_reason
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||||
@@ -1084,9 +1141,13 @@ def stream_wifi():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.wifi_queue.get(timeout=1)
|
msg = app_module.wifi_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
yield format_sse(msg)
|
try:
|
||||||
|
process_event('wifi', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
+50
-28
@@ -16,14 +16,16 @@ from typing import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.wifi import (
|
from utils.wifi import (
|
||||||
get_wifi_scanner,
|
get_wifi_scanner,
|
||||||
analyze_channels,
|
analyze_channels,
|
||||||
get_hidden_correlator,
|
get_hidden_correlator,
|
||||||
SCAN_MODE_QUICK,
|
SCAN_MODE_QUICK,
|
||||||
SCAN_MODE_DEEP,
|
SCAN_MODE_DEEP,
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.validation import validate_wifi_channel
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -85,28 +87,44 @@ def start_deep_scan():
|
|||||||
|
|
||||||
Requires monitor mode interface and root privileges.
|
Requires monitor mode interface and root privileges.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||||
band: Band to scan ('2.4', '5', 'all')
|
band: Band to scan ('2.4', '5', 'all')
|
||||||
channel: Optional specific channel to monitor
|
channel: Optional specific channel to monitor
|
||||||
|
channels: Optional list or comma-separated channels to monitor
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
interface = data.get('interface')
|
interface = data.get('interface')
|
||||||
band = data.get('band', 'all')
|
band = data.get('band', 'all')
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
|
channels = data.get('channels')
|
||||||
if channel:
|
|
||||||
try:
|
channel_list = None
|
||||||
channel = int(channel)
|
if channels:
|
||||||
except ValueError:
|
if isinstance(channels, str):
|
||||||
return jsonify({'error': 'Invalid channel'}), 400
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
|
channel_list = list(channels)
|
||||||
|
else:
|
||||||
|
channel_list = [channels]
|
||||||
|
try:
|
||||||
|
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'Invalid channels'}), 400
|
||||||
|
|
||||||
|
if channel:
|
||||||
|
try:
|
||||||
|
channel = validate_wifi_channel(channel)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'Invalid channel'}), 400
|
||||||
|
|
||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
success = scanner.start_deep_scan(
|
success = scanner.start_deep_scan(
|
||||||
interface=interface,
|
interface=interface,
|
||||||
band=band,
|
band=band,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
)
|
channels=channel_list,
|
||||||
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -388,10 +406,14 @@ def event_stream():
|
|||||||
- keepalive: Periodic keepalive
|
- keepalive: Periodic keepalive
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
|
|
||||||
for event in scanner.get_event_stream():
|
for event in scanner.get_event_stream():
|
||||||
yield format_sse(event)
|
try:
|
||||||
|
process_event('wifi', event, event.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(event)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ detect_dragonos() {
|
|||||||
# Required tool checks (with alternates)
|
# Required tool checks (with alternates)
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
missing_required=()
|
missing_required=()
|
||||||
|
missing_recommended=()
|
||||||
|
|
||||||
check_required() {
|
check_required() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
@@ -178,6 +179,18 @@ check_required() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_recommended() {
|
||||||
|
local label="$1"; shift
|
||||||
|
local desc="$1"; shift
|
||||||
|
|
||||||
|
if have_any "$@"; then
|
||||||
|
ok "${label} - ${desc}"
|
||||||
|
else
|
||||||
|
warn "${label} - ${desc} (missing, recommended)"
|
||||||
|
missing_recommended+=("$label")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
check_optional() {
|
check_optional() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
local desc="$1"; shift
|
local desc="$1"; shift
|
||||||
@@ -201,11 +214,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 "slowrx" "SSTV decoder (ISS images)" slowrx
|
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
|
||||||
@@ -390,42 +404,6 @@ install_rtlamr_from_source() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_slowrx_from_source_macos() {
|
|
||||||
info "slowrx not available via Homebrew. Building from source..."
|
|
||||||
|
|
||||||
# Ensure build dependencies are installed
|
|
||||||
brew_install fftw
|
|
||||||
brew_install libsndfile
|
|
||||||
brew_install gtk+3
|
|
||||||
brew_install pkg-config
|
|
||||||
|
|
||||||
(
|
|
||||||
tmp_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
|
||||||
|
|
||||||
info "Cloning slowrx..."
|
|
||||||
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
|
||||||
|| { warn "Failed to clone slowrx"; exit 1; }
|
|
||||||
|
|
||||||
cd "$tmp_dir/slowrx"
|
|
||||||
info "Compiling slowrx..."
|
|
||||||
# slowrx uses a plain Makefile, not CMake
|
|
||||||
local make_log
|
|
||||||
make_log=$(make 2>&1) || {
|
|
||||||
warn "make failed for slowrx:"
|
|
||||||
echo "$make_log" | tail -20
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install to /usr/local/bin
|
|
||||||
if [[ -w /usr/local/bin ]]; then
|
|
||||||
install -m 0755 slowrx /usr/local/bin/slowrx
|
|
||||||
else
|
|
||||||
sudo install -m 0755 slowrx /usr/local/bin/slowrx
|
|
||||||
fi
|
|
||||||
ok "slowrx installed successfully from source"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
install_multimon_ng_from_source_macos() {
|
install_multimon_ng_from_source_macos() {
|
||||||
info "multimon-ng not available via Homebrew. Building from source..."
|
info "multimon-ng not available via Homebrew. Building from source..."
|
||||||
@@ -642,8 +620,93 @@ 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 may take a while)..."
|
||||||
|
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >/dev/null 2>&1 \
|
||||||
|
&& make -j "$(nproc)" >/dev/null 2>&1; then
|
||||||
|
$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
|
||||||
|
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||||
|
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 may take a while)..."
|
||||||
|
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >/dev/null 2>&1 \
|
||||||
|
&& make -j "$(sysctl -n hw.ncpu)" >/dev/null 2>&1; then
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
make install >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
sudo make install >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
ok "SatDump installed successfully."
|
||||||
|
else
|
||||||
|
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=17
|
TOTAL_STEPS=19
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -663,8 +726,8 @@ install_macos_packages() {
|
|||||||
progress "Installing direwolf (APRS decoder)"
|
progress "Installing direwolf (APRS decoder)"
|
||||||
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||||
|
|
||||||
progress "Skipping slowrx (SSTV decoder)"
|
progress "SSTV decoder"
|
||||||
warn "slowrx requires ALSA (Linux-only) and cannot build on macOS. Skipping."
|
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||||
|
|
||||||
progress "Installing DSD (Digital Speech Decoder, optional)"
|
progress "Installing DSD (Digital Speech Decoder, optional)"
|
||||||
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
||||||
@@ -685,6 +748,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
|
||||||
@@ -720,6 +786,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
|
||||||
|
|
||||||
@@ -882,37 +961,6 @@ install_aiscatcher_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
install_slowrx_from_source_debian() {
|
|
||||||
info "slowrx not available via APT. Building from source..."
|
|
||||||
|
|
||||||
# slowrx uses a simple Makefile, not CMake
|
|
||||||
apt_install build-essential git pkg-config \
|
|
||||||
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
|
|
||||||
|
|
||||||
# Run in subshell to isolate EXIT trap
|
|
||||||
(
|
|
||||||
tmp_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
|
||||||
|
|
||||||
info "Cloning slowrx..."
|
|
||||||
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
|
||||||
|| { warn "Failed to clone slowrx"; exit 1; }
|
|
||||||
|
|
||||||
cd "$tmp_dir/slowrx"
|
|
||||||
|
|
||||||
info "Compiling slowrx..."
|
|
||||||
local make_log
|
|
||||||
make_log=$(make 2>&1) || {
|
|
||||||
warn "make failed for slowrx:"
|
|
||||||
echo "$make_log" | tail -20
|
|
||||||
warn "ISS SSTV decoding will not be available."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
|
|
||||||
ok "slowrx installed successfully."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
install_ubertooth_from_source_debian() {
|
install_ubertooth_from_source_debian() {
|
||||||
info "Building Ubertooth from source..."
|
info "Building Ubertooth from source..."
|
||||||
|
|
||||||
@@ -1048,7 +1096,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=22
|
TOTAL_STEPS=26
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1104,8 +1152,8 @@ install_debian_packages() {
|
|||||||
progress "Installing direwolf (APRS decoder)"
|
progress "Installing direwolf (APRS decoder)"
|
||||||
apt_install direwolf || true
|
apt_install direwolf || true
|
||||||
|
|
||||||
progress "Installing slowrx (SSTV decoder)"
|
progress "SSTV decoder"
|
||||||
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian || warn "slowrx not available. ISS SSTV decoding will not be available."
|
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||||
|
|
||||||
progress "Installing DSD (Digital Speech Decoder, optional)"
|
progress "Installing DSD (Digital Speech Decoder, optional)"
|
||||||
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
||||||
@@ -1126,6 +1174,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
|
||||||
@@ -1205,6 +1256,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
|
||||||
|
|
||||||
@@ -1254,6 +1318,14 @@ final_summary_and_hard_fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${#missing_recommended[@]}" -gt 0 ]]; then
|
||||||
|
echo
|
||||||
|
warn "Missing RECOMMENDED tools (some features will not work):"
|
||||||
|
for t in "${missing_recommended[@]}"; do echo " - $t"; done
|
||||||
|
echo
|
||||||
|
warn "Install these for full functionality"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -1300,6 +1372,19 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
install_python_deps
|
install_python_deps
|
||||||
|
|
||||||
|
# Download leaflet-heat plugin (offline mode)
|
||||||
|
if [ ! -f "static/vendor/leaflet-heat/leaflet-heat.js" ]; then
|
||||||
|
info "Downloading leaflet-heat plugin..."
|
||||||
|
mkdir -p static/vendor/leaflet-heat
|
||||||
|
if curl -sL "https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js" \
|
||||||
|
-o static/vendor/leaflet-heat/leaflet-heat.js; then
|
||||||
|
ok "leaflet-heat plugin downloaded"
|
||||||
|
else
|
||||||
|
warn "Failed to download leaflet-heat plugin. Heatmap will use CDN."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
final_summary_and_hard_fail
|
final_summary_and_hard_fail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Strip title badge */
|
||||||
|
.function-strip .strip-title {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Stats */
|
/* Stats */
|
||||||
.function-strip .strip-stat {
|
.function-strip .strip-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+132
-3
@@ -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;
|
||||||
@@ -4201,6 +4313,12 @@ header h1 .tagline {
|
|||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-detail-btn.active {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border-color: rgba(34, 197, 94, 0.6);
|
||||||
|
color: #9fffd1;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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);
|
||||||
@@ -4392,6 +4510,17 @@ header h1 .tagline {
|
|||||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-history-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-device-name {
|
.bt-device-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -329,12 +331,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-general-image-card {
|
.sstv-general-image-card {
|
||||||
|
position: relative;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-general-image-card:hover {
|
.sstv-general-image-card:hover {
|
||||||
@@ -343,6 +345,10 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-general-image-card-inner {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-general-image-preview {
|
.sstv-general-image-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
@@ -351,6 +357,48 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-general-image-actions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-image-card:hover .sstv-general-image-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-image-actions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-image-actions button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-image-actions button:last-child:hover {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-general-image-info {
|
.sstv-general-image-info {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
@@ -389,6 +437,96 @@
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SIGNAL MONITOR
|
||||||
|
============================================ */
|
||||||
|
.sstv-general-signal-monitor {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-monitor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-monitor-header svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-level-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-level-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-level-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-status-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-vis-state {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-signal-vis-state.active {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
IMAGE MODAL
|
IMAGE MODAL
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -417,6 +555,40 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-general-modal-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 60px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-modal-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-modal-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-modal-btn.delete:hover {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-general-modal-close {
|
.sstv-general-modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
@@ -428,12 +600,33 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-general-modal-close:hover {
|
.sstv-general-modal-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clear All button */
|
||||||
|
.sstv-general-gallery-clear-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-general-gallery-clear-btn:hover {
|
||||||
|
color: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
+195
-2
@@ -7,7 +7,9 @@
|
|||||||
MODE VISIBILITY
|
MODE VISIBILITY
|
||||||
============================================ */
|
============================================ */
|
||||||
#sstvMode.active {
|
#sstvMode.active {
|
||||||
display: block !important;
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -388,12 +390,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-card {
|
.sstv-image-card {
|
||||||
|
position: relative;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-card:hover {
|
.sstv-image-card:hover {
|
||||||
@@ -402,6 +404,10 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-image-card-inner {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-image-preview {
|
.sstv-image-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
@@ -410,6 +416,48 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-image-actions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-card:hover .sstv-image-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-actions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-actions button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-actions button:last-child:hover {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-image-info {
|
.sstv-image-info {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
@@ -736,6 +784,96 @@
|
|||||||
animation: pulse 0.5s infinite;
|
animation: pulse 0.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SIGNAL MONITOR
|
||||||
|
============================================ */
|
||||||
|
.sstv-signal-monitor {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-monitor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-monitor-header svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-level-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-level-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-level-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-status-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-vis-state {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-vis-state.active {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
IMAGE MODAL
|
IMAGE MODAL
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -764,6 +902,40 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-modal-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 60px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-btn.delete:hover {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-modal-close {
|
.sstv-modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
@@ -775,12 +947,33 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-modal-close:hover {
|
.sstv-modal-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clear All button */
|
||||||
|
.sstv-gallery-clear-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-gallery-clear-btn:hover {
|
||||||
|
color: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -196,6 +196,28 @@
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
.tracker-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 51, 102, 0.2);
|
||||||
|
color: #ff3366;
|
||||||
|
border: 1px solid rgba(255, 51, 102, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.client-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
color: #4a9eff;
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
.known-badge {
|
.known-badge {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
+75
-4
@@ -24,8 +24,11 @@
|
|||||||
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);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
@@ -71,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 {
|
||||||
@@ -115,6 +124,9 @@
|
|||||||
|
|
||||||
.settings-section.active {
|
.settings-section.active {
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group {
|
.settings-group {
|
||||||
@@ -163,6 +175,47 @@
|
|||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings Feed Lists */
|
||||||
|
.settings-feed {
|
||||||
|
background: var(--bg-tertiary, #12121f);
|
||||||
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-meta {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-empty {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toggle Switch */
|
/* Toggle Switch */
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -427,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;
|
||||||
@@ -436,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;
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
const AlertCenter = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let alerts = [];
|
||||||
|
let rules = [];
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
loadRules();
|
||||||
|
loadFeed();
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
eventSource = new EventSource('/alerts/stream');
|
||||||
|
eventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'keepalive') return;
|
||||||
|
handleAlert(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Alerts] SSE parse error', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
console.warn('[Alerts] SSE connection error');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlert(alert) {
|
||||||
|
alerts.unshift(alert);
|
||||||
|
alerts = alerts.slice(0, 50);
|
||||||
|
updateFeedUI();
|
||||||
|
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
const severity = (alert.severity || '').toLowerCase();
|
||||||
|
if (['high', 'critical'].includes(severity)) {
|
||||||
|
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeedUI() {
|
||||||
|
const list = document.getElementById('alertsFeedList');
|
||||||
|
const countEl = document.getElementById('alertsFeedCount');
|
||||||
|
if (countEl) countEl.textContent = `(${alerts.length})`;
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = alerts.map(alert => {
|
||||||
|
const title = escapeHtml(alert.title || 'Alert');
|
||||||
|
const message = escapeHtml(alert.message || '');
|
||||||
|
const severity = escapeHtml(alert.severity || 'medium');
|
||||||
|
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${title}</span>
|
||||||
|
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">${message}</div>
|
||||||
|
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFeed() {
|
||||||
|
fetch('/alerts/events?limit=20')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alerts = data.events || [];
|
||||||
|
updateFeedUI();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Alerts] Load feed failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRules() {
|
||||||
|
fetch('/alerts/rules?all=1')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
rules = data.rules || [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Alerts] Load rules failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableTrackerAlerts() {
|
||||||
|
ensureTrackerRule(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableTrackerAlerts() {
|
||||||
|
ensureTrackerRule(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrackerRule(enabled) {
|
||||||
|
loadRules();
|
||||||
|
setTimeout(() => {
|
||||||
|
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
||||||
|
if (existing) {
|
||||||
|
fetch(`/alerts/rules/${existing.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
}).then(() => loadRules());
|
||||||
|
} else if (enabled) {
|
||||||
|
fetch('/alerts/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: TRACKER_RULE_NAME,
|
||||||
|
mode: 'bluetooth',
|
||||||
|
event_type: 'device_update',
|
||||||
|
match: { is_tracker: true },
|
||||||
|
severity: 'high',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true }
|
||||||
|
})
|
||||||
|
}).then(() => loadRules());
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBluetoothWatchlist(address, name) {
|
||||||
|
if (!address) return;
|
||||||
|
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/alerts/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
||||||
|
mode: 'bluetooth',
|
||||||
|
event_type: 'device_update',
|
||||||
|
match: { address: address },
|
||||||
|
severity: 'medium',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true }
|
||||||
|
})
|
||||||
|
}).then(() => loadRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBluetoothWatchlist(address) {
|
||||||
|
if (!address) return;
|
||||||
|
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||||
|
if (!existing) return;
|
||||||
|
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||||
|
.then(() => loadRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWatchlisted(address) {
|
||||||
|
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
loadFeed,
|
||||||
|
enableTrackerAlerts,
|
||||||
|
disableTrackerAlerts,
|
||||||
|
addBluetoothWatchlist,
|
||||||
|
removeBluetoothWatchlist,
|
||||||
|
isWatchlisted,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
|
AlertCenter.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const RecordingUI = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let recordings = [];
|
||||||
|
let active = [];
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
fetch('/recordings')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
recordings = data.recordings || [];
|
||||||
|
active = data.active || [];
|
||||||
|
renderActive();
|
||||||
|
renderRecordings();
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Recording] Load failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const modeSelect = document.getElementById('recordingModeSelect');
|
||||||
|
const labelInput = document.getElementById('recordingLabelInput');
|
||||||
|
const mode = modeSelect ? modeSelect.value : '';
|
||||||
|
const label = labelInput ? labelInput.value : '';
|
||||||
|
if (!mode) return;
|
||||||
|
|
||||||
|
fetch('/recordings/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode, label })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Recording] Start failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
const modeSelect = document.getElementById('recordingModeSelect');
|
||||||
|
const mode = modeSelect ? modeSelect.value : '';
|
||||||
|
if (!mode) return;
|
||||||
|
|
||||||
|
fetch('/recordings/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh())
|
||||||
|
.catch(err => console.error('[Recording] Stop failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopById(sessionId) {
|
||||||
|
fetch('/recordings/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: sessionId })
|
||||||
|
}).then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActive() {
|
||||||
|
const container = document.getElementById('recordingActiveList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!active.length) {
|
||||||
|
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = active.map(session => {
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${escapeHtml(session.mode)}</span>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
|
||||||
|
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordings() {
|
||||||
|
const container = document.getElementById('recordingList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!recordings.length) {
|
||||||
|
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = recordings.map(rec => {
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||||
|
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(sessionId) {
|
||||||
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
refresh,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
stopById,
|
||||||
|
download,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
|
RecordingUI.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -922,5 +922,64 @@ function switchSettingsTab(tabName) {
|
|||||||
loadUpdateStatus();
|
loadUpdateStatus();
|
||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
|
} else if (tabName === 'alerts') {
|
||||||
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
|
AlertCenter.loadFeed();
|
||||||
|
}
|
||||||
|
} else if (tabName === 'recording') {
|
||||||
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
|
RecordingUI.refresh();
|
||||||
|
}
|
||||||
|
} else if (tabName === 'apikeys') {
|
||||||
|
loadApiKeyStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load API key status into the API Keys settings tab
|
||||||
|
*/
|
||||||
|
function loadApiKeyStatus() {
|
||||||
|
const badge = document.getElementById('apiKeyStatusBadge');
|
||||||
|
const desc = document.getElementById('apiKeyStatusDesc');
|
||||||
|
const usage = document.getElementById('apiKeyUsageCount');
|
||||||
|
const bar = document.getElementById('apiKeyUsageBar');
|
||||||
|
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
badge.textContent = 'Not available';
|
||||||
|
badge.className = 'asset-badge missing';
|
||||||
|
desc.textContent = 'GSM feature removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save API key from the settings input
|
||||||
|
*/
|
||||||
|
function saveApiKey() {
|
||||||
|
const input = document.getElementById('apiKeyInput');
|
||||||
|
const result = document.getElementById('apiKeySaveResult');
|
||||||
|
if (!input || !result) return;
|
||||||
|
|
||||||
|
const key = input.value.trim();
|
||||||
|
if (!key) {
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.style.color = 'var(--accent-red)';
|
||||||
|
result.textContent = 'Please enter an API key.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.style.color = 'var(--text-dim)';
|
||||||
|
result.textContent = 'Saving...';
|
||||||
|
|
||||||
|
result.style.color = 'var(--accent-red)';
|
||||||
|
result.textContent = 'GSM feature has been removed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle API key input visibility
|
||||||
|
*/
|
||||||
|
function toggleApiKeyVisibility() {
|
||||||
|
const input = document.getElementById('apiKeyInput');
|
||||||
|
if (!input) return;
|
||||||
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
|
}
|
||||||
|
|||||||
@@ -366,7 +366,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) {
|
||||||
|
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Tracker badge
|
// Tracker badge
|
||||||
if (device.is_tracker) {
|
if (device.is_tracker) {
|
||||||
@@ -448,12 +451,14 @@ 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);
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const servicesContainer = document.getElementById('btDetailServices');
|
const servicesContainer = document.getElementById('btDetailServices');
|
||||||
@@ -465,13 +470,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
|
||||||
|
*/
|
||||||
|
function updateWatchlistButton(device) {
|
||||||
|
const btn = document.getElementById('btDetailWatchBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
if (typeof AlertCenter === 'undefined') {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.style.display = '';
|
||||||
|
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||||
|
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||||
|
btn.classList.toggle('active', watchlisted);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device selection
|
* Clear device selection
|
||||||
@@ -525,24 +546,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.querySelector('.bt-detail-btn');
|
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
|
||||||
|
*/
|
||||||
|
function toggleWatchlist() {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const device = devices.get(selectedDeviceId);
|
||||||
|
if (!device || typeof AlertCenter === 'undefined') return;
|
||||||
|
|
||||||
|
if (AlertCenter.isWatchlisted(device.address)) {
|
||||||
|
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||||
|
showInfo('Removed from watchlist');
|
||||||
|
} else {
|
||||||
|
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||||
|
showInfo('Added to watchlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => updateWatchlistButton(device), 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a device - opens modal with details
|
* Select a device - opens modal with details
|
||||||
@@ -1090,10 +1130,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;
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -1145,8 +1186,9 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
// 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 + '×');
|
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>');
|
||||||
@@ -1358,9 +1400,10 @@ const BluetoothMode = (function() {
|
|||||||
setBaseline,
|
setBaseline,
|
||||||
clearBaseline,
|
clearBaseline,
|
||||||
exportData,
|
exportData,
|
||||||
selectDevice,
|
selectDevice,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
copyAddress,
|
copyAddress,
|
||||||
|
toggleWatchlist,
|
||||||
|
|
||||||
// Agent handling
|
// Agent handling
|
||||||
handleAgentChange,
|
handleAgentChange,
|
||||||
|
|||||||
+413
-15
@@ -10,6 +10,14 @@ let dmrCallCount = 0;
|
|||||||
let dmrSyncCount = 0;
|
let dmrSyncCount = 0;
|
||||||
let dmrCallHistory = [];
|
let dmrCallHistory = [];
|
||||||
let dmrCurrentProtocol = '--';
|
let dmrCurrentProtocol = '--';
|
||||||
|
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;
|
||||||
@@ -37,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';
|
||||||
@@ -47,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(() => {});
|
||||||
}
|
}
|
||||||
@@ -57,12 +76,30 @@ function startDmr() {
|
|||||||
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
||||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
||||||
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
||||||
|
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
||||||
|
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.
|
||||||
|
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
||||||
|
|
||||||
|
// Check device availability before starting
|
||||||
|
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
|
||||||
|
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 })
|
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -80,8 +117,30 @@ function startDmr() {
|
|||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
|
if (typeof reserveDevice === 'function') {
|
||||||
|
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('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||||
|
}
|
||||||
|
} else if (data.status === 'error' && data.message === 'Already running') {
|
||||||
|
// Backend has an active session the frontend lost track of — resync
|
||||||
|
isDmrRunning = true;
|
||||||
|
updateDmrUI();
|
||||||
|
connectDmrSSE();
|
||||||
|
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||||
|
dmrEventType = 'idle';
|
||||||
|
dmrActivityTarget = 0.1;
|
||||||
|
dmrLastEventTime = Date.now();
|
||||||
|
updateDmrSynthStatus();
|
||||||
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('DMR', 'Reconnected to active session');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
@@ -93,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(() => {
|
||||||
@@ -102,8 +162,12 @@ 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') {
|
||||||
|
releaseDevice(dmrModeLabel);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[DMR] Stop error:', err));
|
.catch(err => console.error('[DMR] Stop error:', err));
|
||||||
}
|
}
|
||||||
@@ -146,6 +210,11 @@ function handleDmrMessage(msg) {
|
|||||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
||||||
|
|
||||||
// Update current call display
|
// Update current call display
|
||||||
|
const slotInfo = msg.slot != null ? `
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||||
|
<span style="color: var(--text-muted);">Slot</span>
|
||||||
|
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
||||||
|
</div>` : '';
|
||||||
const callEl = document.getElementById('dmrCurrentCall');
|
const callEl = document.getElementById('dmrCurrentCall');
|
||||||
if (callEl) {
|
if (callEl) {
|
||||||
callEl.innerHTML = `
|
callEl.innerHTML = `
|
||||||
@@ -156,7 +225,7 @@ function handleDmrMessage(msg) {
|
|||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||||
<span style="color: var(--text-muted);">Source ID</span>
|
<span style="color: var(--text-muted);">Source ID</span>
|
||||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
||||||
</div>
|
</div>${slotInfo}
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-muted);">Time</span>
|
<span style="color: var(--text-muted);">Time</span>
|
||||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||||
@@ -176,14 +245,46 @@ function handleDmrMessage(msg) {
|
|||||||
|
|
||||||
} else if (msg.type === 'slot') {
|
} else if (msg.type === 'slot') {
|
||||||
// Update slot info in current call
|
// Update slot info in current call
|
||||||
|
} else if (msg.type === 'raw') {
|
||||||
|
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
|
||||||
|
} else if (msg.type === 'heartbeat') {
|
||||||
|
// Decoder is alive and listening — keep synthesizer in listening state
|
||||||
|
if (isDmrRunning && dmrSynthInitialized) {
|
||||||
|
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
|
||||||
|
dmrEventType = 'raw';
|
||||||
|
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
|
||||||
|
dmrLastEventTime = Date.now();
|
||||||
|
updateDmrSynthStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (msg.type === 'status') {
|
} else if (msg.type === 'status') {
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) {
|
if (msg.text === 'started') {
|
||||||
statusEl.textContent = msg.text === 'started' ? 'DECODING' : 'IDLE';
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
}
|
} else if (msg.text === 'crashed') {
|
||||||
if (msg.text === 'stopped') {
|
|
||||||
isDmrRunning = false;
|
isDmrRunning = false;
|
||||||
|
stopDmrAudio();
|
||||||
updateDmrUI();
|
updateDmrUI();
|
||||||
|
dmrEventType = 'stopped';
|
||||||
|
dmrActivityTarget = 0;
|
||||||
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
|
if (statusEl) statusEl.textContent = 'CRASHED';
|
||||||
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
|
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('DMR Error', detail);
|
||||||
|
}
|
||||||
|
} else if (msg.text === 'stopped') {
|
||||||
|
isDmrRunning = false;
|
||||||
|
stopDmrAudio();
|
||||||
|
updateDmrUI();
|
||||||
|
dmrEventType = 'stopped';
|
||||||
|
dmrActivityTarget = 0;
|
||||||
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,12 +363,14 @@ function drawDmrSynthesizer() {
|
|||||||
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||||
dmrSynthCtx.fillRect(0, 0, width, height);
|
dmrSynthCtx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
// Decay activity toward target
|
// Decay activity toward target. Window must exceed the backend
|
||||||
|
// heartbeat interval (3s) so the status doesn't flip-flop between
|
||||||
|
// LISTENING and IDLE on every heartbeat cycle.
|
||||||
const timeSinceEvent = now - dmrLastEventTime;
|
const timeSinceEvent = now - dmrLastEventTime;
|
||||||
if (timeSinceEvent > 2000) {
|
if (timeSinceEvent > 5000) {
|
||||||
// No events for 2s — decay target toward idle
|
// No events for 5s — decay target toward idle
|
||||||
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
||||||
if (dmrActivityTarget < 0.05 && dmrEventType !== 'stopped') {
|
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
||||||
dmrEventType = 'idle';
|
dmrEventType = 'idle';
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
}
|
}
|
||||||
@@ -280,9 +383,9 @@ function drawDmrSynthesizer() {
|
|||||||
let effectiveActivity = dmrActivityLevel;
|
let effectiveActivity = dmrActivityLevel;
|
||||||
if (dmrEventType === 'stopped') {
|
if (dmrEventType === 'stopped') {
|
||||||
effectiveActivity = 0;
|
effectiveActivity = 0;
|
||||||
} else if (effectiveActivity < 0.05 && isDmrRunning) {
|
} else if (effectiveActivity < 0.1 && isDmrRunning) {
|
||||||
// Gentle idle breathing
|
// Visible idle breathing — shows decoder is alive and listening
|
||||||
effectiveActivity = 0.05 + Math.sin(now / 800) * 0.035;
|
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ripple timing for sync events
|
// Ripple timing for sync events
|
||||||
@@ -399,6 +502,10 @@ function dmrSynthPulse(type) {
|
|||||||
dmrEventType = 'voice';
|
dmrEventType = 'voice';
|
||||||
} else if (type === 'slot' || type === 'nac') {
|
} else if (type === 'slot' || type === 'nac') {
|
||||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
||||||
|
} else if (type === 'raw') {
|
||||||
|
// Any DSD output means the decoder is alive and processing
|
||||||
|
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
|
||||||
|
if (dmrEventType === 'idle') dmrEventType = 'raw';
|
||||||
}
|
}
|
||||||
// keepalive and status don't change visuals
|
// keepalive and status don't change visuals
|
||||||
|
|
||||||
@@ -412,6 +519,7 @@ function updateDmrSynthStatus() {
|
|||||||
const labels = {
|
const labels = {
|
||||||
stopped: 'STOPPED',
|
stopped: 'STOPPED',
|
||||||
idle: 'IDLE',
|
idle: 'IDLE',
|
||||||
|
raw: 'LISTENING',
|
||||||
sync: 'SYNC',
|
sync: 'SYNC',
|
||||||
call: 'CALL',
|
call: 'CALL',
|
||||||
voice: 'VOICE'
|
voice: 'VOICE'
|
||||||
@@ -419,6 +527,7 @@ function updateDmrSynthStatus() {
|
|||||||
const colors = {
|
const colors = {
|
||||||
stopped: 'var(--text-muted)',
|
stopped: 'var(--text-muted)',
|
||||||
idle: 'var(--text-muted)',
|
idle: 'var(--text-muted)',
|
||||||
|
raw: '#607d8b',
|
||||||
sync: '#00e5ff',
|
sync: '#00e5ff',
|
||||||
call: '#4caf50',
|
call: '#4caf50',
|
||||||
voice: '#ff9800'
|
voice: '#ff9800'
|
||||||
@@ -446,9 +555,298 @@ 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 ==============
|
||||||
|
|
||||||
|
function checkDmrStatus() {
|
||||||
|
fetch('/dmr/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.running && !isDmrRunning) {
|
||||||
|
// Backend is running but frontend lost track — resync
|
||||||
|
isDmrRunning = true;
|
||||||
|
updateDmrUI();
|
||||||
|
connectDmrSSE();
|
||||||
|
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||||
|
dmrEventType = 'idle';
|
||||||
|
dmrActivityTarget = 0.1;
|
||||||
|
dmrLastEventTime = Date.now();
|
||||||
|
updateDmrSynthStatus();
|
||||||
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
|
} else if (!data.running && isDmrRunning) {
|
||||||
|
// Backend stopped but frontend didn't know
|
||||||
|
isDmrRunning = false;
|
||||||
|
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||||
|
updateDmrUI();
|
||||||
|
dmrEventType = 'stopped';
|
||||||
|
dmrActivityTarget = 0;
|
||||||
|
updateDmrSynthStatus();
|
||||||
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== INIT ==============
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
restoreDmrSettings();
|
||||||
|
loadDmrBookmarks();
|
||||||
|
});
|
||||||
|
|
||||||
// ============== EXPORTS ==============
|
// ============== EXPORTS ==============
|
||||||
|
|
||||||
window.startDmr = startDmr;
|
window.startDmr = startDmr;
|
||||||
window.stopDmr = stopDmr;
|
window.stopDmr = stopDmr;
|
||||||
window.checkDmrTools = checkDmrTools;
|
window.checkDmrTools = checkDmrTools;
|
||||||
|
window.checkDmrStatus = checkDmrStatus;
|
||||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||||
|
window.setDmrAudioVolume = setDmrAudioVolume;
|
||||||
|
window.addDmrBookmark = addDmrBookmark;
|
||||||
|
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
|
||||||
|
window.removeDmrBookmark = removeDmrBookmark;
|
||||||
|
window.dmrQuickTune = dmrQuickTune;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+361
-15
@@ -11,6 +11,18 @@ const SSTVGeneral = (function() {
|
|||||||
let currentMode = null;
|
let currentMode = null;
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
|
|
||||||
|
// Signal scope state
|
||||||
|
let sstvGeneralScopeCtx = null;
|
||||||
|
let sstvGeneralScopeAnim = null;
|
||||||
|
let sstvGeneralScopeHistory = [];
|
||||||
|
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||||
|
let sstvGeneralScopeRms = 0;
|
||||||
|
let sstvGeneralScopePeak = 0;
|
||||||
|
let sstvGeneralScopeTargetRms = 0;
|
||||||
|
let sstvGeneralScopeTargetPeak = 0;
|
||||||
|
let sstvGeneralScopeMsgBurst = 0;
|
||||||
|
let sstvGeneralScopeTone = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV General mode
|
* Initialize the SSTV General mode
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +64,7 @@ const SSTVGeneral = (function() {
|
|||||||
|
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
updateStatusUI('unavailable', 'Decoder not installed');
|
updateStatusUI('unavailable', 'Decoder not installed');
|
||||||
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
|
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +202,136 @@ const SSTVGeneral = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize signal scope canvas
|
||||||
|
*/
|
||||||
|
function initSstvGeneralScope() {
|
||||||
|
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||||
|
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||||
|
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||||
|
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||||
|
sstvGeneralScopeRms = 0;
|
||||||
|
sstvGeneralScopePeak = 0;
|
||||||
|
sstvGeneralScopeTargetRms = 0;
|
||||||
|
sstvGeneralScopeTargetPeak = 0;
|
||||||
|
sstvGeneralScopeMsgBurst = 0;
|
||||||
|
sstvGeneralScopeTone = null;
|
||||||
|
drawSstvGeneralScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw signal scope animation frame
|
||||||
|
*/
|
||||||
|
function drawSstvGeneralScope() {
|
||||||
|
const ctx = sstvGeneralScopeCtx;
|
||||||
|
if (!ctx) return;
|
||||||
|
const W = ctx.canvas.width;
|
||||||
|
const H = ctx.canvas.height;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
|
// Phosphor persistence
|
||||||
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Smooth towards target
|
||||||
|
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
|
||||||
|
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
|
||||||
|
|
||||||
|
// Push to history
|
||||||
|
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
|
||||||
|
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
const y = (H / 4) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const x = (W / 8) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waveform
|
||||||
|
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||||
|
ctx.strokeStyle = '#c080ff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = 4;
|
||||||
|
|
||||||
|
// Upper half
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY - amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Lower half (mirror)
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY + amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Peak indicator
|
||||||
|
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
|
||||||
|
if (peakNorm > 0.01) {
|
||||||
|
const peakY = midY - peakNorm * midY * 0.9;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image decode flash
|
||||||
|
if (sstvGeneralScopeMsgBurst > 0.01) {
|
||||||
|
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
sstvGeneralScopeMsgBurst *= 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update labels
|
||||||
|
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
|
||||||
|
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
|
||||||
|
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
|
||||||
|
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
|
||||||
|
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
|
||||||
|
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
|
||||||
|
if (toneLabel) {
|
||||||
|
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||||
|
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||||
|
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||||
|
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||||
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
|
}
|
||||||
|
if (statusLabel) {
|
||||||
|
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||||
|
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop signal scope
|
||||||
|
*/
|
||||||
|
function stopSstvGeneralScope() {
|
||||||
|
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||||
|
sstvGeneralScopeCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start SSE stream
|
* Start SSE stream
|
||||||
*/
|
*/
|
||||||
@@ -198,6 +340,11 @@ const SSTVGeneral = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show and init scope
|
||||||
|
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'block';
|
||||||
|
initSstvGeneralScope();
|
||||||
|
|
||||||
eventSource = new EventSource('/sstv-general/stream');
|
eventSource = new EventSource('/sstv-general/stream');
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
@@ -205,6 +352,10 @@ const SSTVGeneral = (function() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
|
} else if (data.type === 'sstv_scope') {
|
||||||
|
sstvGeneralScopeTargetRms = data.rms;
|
||||||
|
sstvGeneralScopeTargetPeak = data.peak;
|
||||||
|
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
@@ -227,6 +378,9 @@ const SSTVGeneral = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
stopSstvGeneralScope();
|
||||||
|
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,8 +399,97 @@ const SSTVGeneral = (function() {
|
|||||||
renderGallery();
|
renderGallery();
|
||||||
showNotification('SSTV', 'New image decoded!');
|
showNotification('SSTV', 'New image decoded!');
|
||||||
updateStatusUI('listening', 'Listening...');
|
updateStatusUI('listening', 'Listening...');
|
||||||
|
sstvGeneralScopeMsgBurst = 1.0;
|
||||||
|
// Clear decode progress so signal monitor can take over
|
||||||
|
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||||
|
if (liveContent) liveContent.innerHTML = '';
|
||||||
} else if (data.status === 'detecting') {
|
} else if (data.status === 'detecting') {
|
||||||
|
// Ignore detecting events if currently decoding (e.g. Doppler updates)
|
||||||
|
const dot = document.getElementById('sstvGeneralStripDot');
|
||||||
|
if (dot && dot.classList.contains('decoding')) return;
|
||||||
|
|
||||||
updateStatusUI('listening', data.message || 'Listening...');
|
updateStatusUI('listening', data.message || 'Listening...');
|
||||||
|
if (data.signal_level !== undefined) {
|
||||||
|
renderSignalMonitor(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render signal monitor in live area during detecting mode
|
||||||
|
*/
|
||||||
|
function renderSignalMonitor(data) {
|
||||||
|
const container = document.getElementById('sstvGeneralLiveContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const level = data.signal_level || 0;
|
||||||
|
const tone = data.sstv_tone;
|
||||||
|
|
||||||
|
let barColor, statusText;
|
||||||
|
if (tone === 'leader') {
|
||||||
|
barColor = 'var(--accent-green)';
|
||||||
|
statusText = 'SSTV leader tone detected';
|
||||||
|
} else if (tone === 'sync') {
|
||||||
|
barColor = 'var(--accent-cyan)';
|
||||||
|
statusText = 'SSTV sync pulse detected';
|
||||||
|
} else if (tone === 'noise') {
|
||||||
|
barColor = 'var(--text-dim)';
|
||||||
|
statusText = 'Audio signal present';
|
||||||
|
} else if (level > 10) {
|
||||||
|
barColor = 'var(--text-dim)';
|
||||||
|
statusText = 'Audio signal present';
|
||||||
|
} else {
|
||||||
|
barColor = 'var(--text-dim)';
|
||||||
|
statusText = 'No signal';
|
||||||
|
}
|
||||||
|
|
||||||
|
let monitor = container.querySelector('.sstv-general-signal-monitor');
|
||||||
|
if (!monitor) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="sstv-general-signal-monitor">
|
||||||
|
<div class="sstv-general-signal-monitor-header">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
|
||||||
|
<circle cx="12" cy="18" r="2"/>
|
||||||
|
<path d="M12 16V12"/>
|
||||||
|
</svg>
|
||||||
|
Signal Monitor
|
||||||
|
</div>
|
||||||
|
<div class="sstv-general-signal-level-row">
|
||||||
|
<span class="sstv-general-signal-level-label">LEVEL</span>
|
||||||
|
<div class="sstv-general-signal-bar-track">
|
||||||
|
<div class="sstv-general-signal-bar-fill" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="sstv-general-signal-level-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-general-signal-status-text">No signal</div>
|
||||||
|
<div class="sstv-general-signal-vis-state">VIS: idle</div>
|
||||||
|
</div>`;
|
||||||
|
monitor = container.querySelector('.sstv-general-signal-monitor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fill = monitor.querySelector('.sstv-general-signal-bar-fill');
|
||||||
|
fill.style.width = level + '%';
|
||||||
|
fill.style.background = barColor;
|
||||||
|
monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText;
|
||||||
|
monitor.querySelector('.sstv-general-signal-level-value').textContent = level;
|
||||||
|
|
||||||
|
const visStateEl = monitor.querySelector('.sstv-general-signal-vis-state');
|
||||||
|
if (visStateEl && data.vis_state) {
|
||||||
|
const stateLabels = {
|
||||||
|
'idle': 'Idle',
|
||||||
|
'leader_1': 'Leader',
|
||||||
|
'break': 'Break',
|
||||||
|
'leader_2': 'Leader 2',
|
||||||
|
'start_bit': 'Start bit',
|
||||||
|
'data_bits': 'Data bits',
|
||||||
|
'parity': 'Parity',
|
||||||
|
'stop_bit': 'Stop bit',
|
||||||
|
};
|
||||||
|
const label = stateLabels[data.vis_state] || data.vis_state;
|
||||||
|
visStateEl.textContent = 'VIS: ' + label;
|
||||||
|
visStateEl.className = 'sstv-general-signal-vis-state' +
|
||||||
|
(data.vis_state !== 'idle' ? ' active' : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,18 +500,33 @@ const SSTVGeneral = (function() {
|
|||||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||||
if (!liveContent) return;
|
if (!liveContent) return;
|
||||||
|
|
||||||
liveContent.innerHTML = `
|
let container = liveContent.querySelector('.sstv-general-decode-container');
|
||||||
<div class="sstv-general-canvas-container">
|
if (!container) {
|
||||||
<canvas id="sstvGeneralCanvas" width="320" height="256"></canvas>
|
liveContent.innerHTML = `
|
||||||
</div>
|
<div class="sstv-general-decode-container">
|
||||||
<div class="sstv-general-decode-info">
|
<div class="sstv-general-canvas-container">
|
||||||
<div class="sstv-general-mode-label">${data.mode || 'Detecting mode...'}</div>
|
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
|
||||||
<div class="sstv-general-progress-bar">
|
</div>
|
||||||
<div class="progress" style="width: ${data.progress || 0}%"></div>
|
<div class="sstv-general-decode-info">
|
||||||
|
<div class="sstv-general-mode-label"></div>
|
||||||
|
<div class="sstv-general-progress-bar">
|
||||||
|
<div class="progress" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-general-status-message"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sstv-general-status-message">${data.message || 'Decoding...'}</div>
|
`;
|
||||||
</div>
|
container = liveContent.querySelector('.sstv-general-decode-container');
|
||||||
`;
|
}
|
||||||
|
|
||||||
|
container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...';
|
||||||
|
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
|
||||||
|
container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...';
|
||||||
|
|
||||||
|
if (data.partial_image) {
|
||||||
|
const img = container.querySelector('#sstvGeneralDecodeImg');
|
||||||
|
if (img) img.src = data.partial_image;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,12 +580,22 @@ const SSTVGeneral = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gallery.innerHTML = images.map(img => `
|
gallery.innerHTML = images.map(img => `
|
||||||
<div class="sstv-general-image-card" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}')">
|
<div class="sstv-general-image-card">
|
||||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
|
<div class="sstv-general-image-card-inner" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
|
||||||
|
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
|
||||||
|
</div>
|
||||||
<div class="sstv-general-image-info">
|
<div class="sstv-general-image-info">
|
||||||
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||||
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sstv-general-image-actions">
|
||||||
|
<button onclick="event.stopPropagation(); SSTVGeneral.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); SSTVGeneral.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -335,19 +603,45 @@ const SSTVGeneral = (function() {
|
|||||||
/**
|
/**
|
||||||
* Show full-size image in modal
|
* Show full-size image in modal
|
||||||
*/
|
*/
|
||||||
function showImage(url) {
|
let currentModalUrl = null;
|
||||||
|
let currentModalFilename = null;
|
||||||
|
|
||||||
|
function showImage(url, filename) {
|
||||||
|
currentModalUrl = url;
|
||||||
|
currentModalFilename = filename || null;
|
||||||
|
|
||||||
let modal = document.getElementById('sstvGeneralImageModal');
|
let modal = document.getElementById('sstvGeneralImageModal');
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
modal = document.createElement('div');
|
modal = document.createElement('div');
|
||||||
modal.id = 'sstvGeneralImageModal';
|
modal.id = 'sstvGeneralImageModal';
|
||||||
modal.className = 'sstv-general-image-modal';
|
modal.className = 'sstv-general-image-modal';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
|
<div class="sstv-general-modal-toolbar">
|
||||||
|
<button class="sstv-general-modal-btn" id="sstvGeneralModalDownload" title="Download">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button class="sstv-general-modal-btn delete" id="sstvGeneralModalDelete" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">×</button>
|
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">×</button>
|
||||||
<img src="" alt="SSTV Image">
|
<img src="" alt="SSTV Image">
|
||||||
`;
|
`;
|
||||||
modal.addEventListener('click', (e) => {
|
modal.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) closeImage();
|
if (e.target === modal) closeImage();
|
||||||
});
|
});
|
||||||
|
modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => {
|
||||||
|
if (currentModalUrl && currentModalFilename) {
|
||||||
|
downloadImage(currentModalUrl, currentModalFilename);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => {
|
||||||
|
if (currentModalFilename) {
|
||||||
|
deleteImage(currentModalFilename);
|
||||||
|
}
|
||||||
|
});
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +680,55 @@ const SSTVGeneral = (function() {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single image
|
||||||
|
*/
|
||||||
|
async function deleteImage(filename) {
|
||||||
|
if (!confirm('Delete this image?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
images = images.filter(img => img.filename !== filename);
|
||||||
|
updateImageCount(images.length);
|
||||||
|
renderGallery();
|
||||||
|
closeImage();
|
||||||
|
showNotification('SSTV', 'Image deleted');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete image:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all images
|
||||||
|
*/
|
||||||
|
async function deleteAllImages() {
|
||||||
|
if (!confirm('Delete all decoded images?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
images = [];
|
||||||
|
updateImageCount(0);
|
||||||
|
renderGallery();
|
||||||
|
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete images:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an image
|
||||||
|
*/
|
||||||
|
function downloadImage(url, filename) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url + '/download';
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show status message
|
* Show status message
|
||||||
*/
|
*/
|
||||||
@@ -405,6 +748,9 @@ const SSTVGeneral = (function() {
|
|||||||
loadImages,
|
loadImages,
|
||||||
showImage,
|
showImage,
|
||||||
closeImage,
|
closeImage,
|
||||||
|
deleteImage,
|
||||||
|
deleteAllImages,
|
||||||
|
downloadImage,
|
||||||
selectPreset
|
selectPreset
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+377
-20
@@ -21,6 +21,18 @@ const SSTV = (function() {
|
|||||||
// ISS frequency
|
// ISS frequency
|
||||||
const ISS_FREQ = 145.800;
|
const ISS_FREQ = 145.800;
|
||||||
|
|
||||||
|
// Signal scope state
|
||||||
|
let sstvScopeCtx = null;
|
||||||
|
let sstvScopeAnim = null;
|
||||||
|
let sstvScopeHistory = [];
|
||||||
|
const SSTV_SCOPE_LEN = 200;
|
||||||
|
let sstvScopeRms = 0;
|
||||||
|
let sstvScopePeak = 0;
|
||||||
|
let sstvScopeTargetRms = 0;
|
||||||
|
let sstvScopeTargetPeak = 0;
|
||||||
|
let sstvScopeMsgBurst = 0;
|
||||||
|
let sstvScopeTone = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV mode
|
* Initialize the SSTV mode
|
||||||
*/
|
*/
|
||||||
@@ -183,11 +195,11 @@ const SSTV = (function() {
|
|||||||
Settings.registerMap(issMap);
|
Settings.registerMap(issMap);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to dark theme tiles
|
// Fallback to dark theme tiles
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
className: 'tile-layer-cyan'
|
className: 'tile-layer-cyan'
|
||||||
}).addTo(issMap);
|
}).addTo(issMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ISS icon
|
// Create ISS icon
|
||||||
const issIcon = L.divIcon({
|
const issIcon = L.divIcon({
|
||||||
@@ -491,7 +503,7 @@ const SSTV = (function() {
|
|||||||
|
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
updateStatusUI('unavailable', 'Decoder not installed');
|
updateStatusUI('unavailable', 'Decoder not installed');
|
||||||
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
|
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +533,11 @@ const SSTV = (function() {
|
|||||||
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
|
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
|
||||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||||
|
|
||||||
|
// Check if device is available
|
||||||
|
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('sstv')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateStatusUI('connecting', 'Starting...');
|
updateStatusUI('connecting', 'Starting...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -534,6 +551,9 @@ const SSTV = (function() {
|
|||||||
|
|
||||||
if (data.status === 'started' || data.status === 'already_running') {
|
if (data.status === 'started' || data.status === 'already_running') {
|
||||||
isRunning = true;
|
isRunning = true;
|
||||||
|
if (typeof reserveDevice === 'function') {
|
||||||
|
reserveDevice(device, 'sstv');
|
||||||
|
}
|
||||||
updateStatusUI('listening', `${frequency} MHz`);
|
updateStatusUI('listening', `${frequency} MHz`);
|
||||||
startStream();
|
startStream();
|
||||||
showNotification('SSTV', `Listening on ${frequency} MHz`);
|
showNotification('SSTV', `Listening on ${frequency} MHz`);
|
||||||
@@ -555,6 +575,9 @@ const SSTV = (function() {
|
|||||||
try {
|
try {
|
||||||
await fetch('/sstv/stop', { method: 'POST' });
|
await fetch('/sstv/stop', { method: 'POST' });
|
||||||
isRunning = false;
|
isRunning = false;
|
||||||
|
if (typeof releaseDevice === 'function') {
|
||||||
|
releaseDevice('sstv');
|
||||||
|
}
|
||||||
stopStream();
|
stopStream();
|
||||||
updateStatusUI('idle', 'Stopped');
|
updateStatusUI('idle', 'Stopped');
|
||||||
showNotification('SSTV', 'Decoder stopped');
|
showNotification('SSTV', 'Decoder stopped');
|
||||||
@@ -623,6 +646,136 @@ const SSTV = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize signal scope canvas
|
||||||
|
*/
|
||||||
|
function initSstvScope() {
|
||||||
|
const canvas = document.getElementById('sstvScopeCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||||
|
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||||
|
sstvScopeCtx = canvas.getContext('2d');
|
||||||
|
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
|
||||||
|
sstvScopeRms = 0;
|
||||||
|
sstvScopePeak = 0;
|
||||||
|
sstvScopeTargetRms = 0;
|
||||||
|
sstvScopeTargetPeak = 0;
|
||||||
|
sstvScopeMsgBurst = 0;
|
||||||
|
sstvScopeTone = null;
|
||||||
|
drawSstvScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw signal scope animation frame
|
||||||
|
*/
|
||||||
|
function drawSstvScope() {
|
||||||
|
const ctx = sstvScopeCtx;
|
||||||
|
if (!ctx) return;
|
||||||
|
const W = ctx.canvas.width;
|
||||||
|
const H = ctx.canvas.height;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
|
// Phosphor persistence
|
||||||
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Smooth towards target
|
||||||
|
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
|
||||||
|
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
|
||||||
|
|
||||||
|
// Push to history
|
||||||
|
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
|
||||||
|
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 1; i < 4; i++) {
|
||||||
|
const y = (H / 4) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const x = (W / 8) * i;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waveform
|
||||||
|
const stepX = W / (SSTV_SCOPE_LEN - 1);
|
||||||
|
ctx.strokeStyle = '#c080ff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = 4;
|
||||||
|
|
||||||
|
// Upper half
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY - amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Lower half (mirror)
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||||
|
const y = midY + amp;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Peak indicator
|
||||||
|
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
|
||||||
|
if (peakNorm > 0.01) {
|
||||||
|
const peakY = midY - peakNorm * midY * 0.9;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image decode flash
|
||||||
|
if (sstvScopeMsgBurst > 0.01) {
|
||||||
|
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
sstvScopeMsgBurst *= 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update labels
|
||||||
|
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
|
||||||
|
const peakLabel = document.getElementById('sstvScopePeakLabel');
|
||||||
|
const toneLabel = document.getElementById('sstvScopeToneLabel');
|
||||||
|
const statusLabel = document.getElementById('sstvScopeStatusLabel');
|
||||||
|
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
|
||||||
|
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
|
||||||
|
if (toneLabel) {
|
||||||
|
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||||
|
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||||
|
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||||
|
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||||
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
|
}
|
||||||
|
if (statusLabel) {
|
||||||
|
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||||
|
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop signal scope
|
||||||
|
*/
|
||||||
|
function stopSstvScope() {
|
||||||
|
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
|
||||||
|
sstvScopeCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start SSE stream
|
* Start SSE stream
|
||||||
*/
|
*/
|
||||||
@@ -631,6 +784,11 @@ const SSTV = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show and init scope
|
||||||
|
const scopePanel = document.getElementById('sstvScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'block';
|
||||||
|
initSstvScope();
|
||||||
|
|
||||||
eventSource = new EventSource('/sstv/stream');
|
eventSource = new EventSource('/sstv/stream');
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
@@ -638,6 +796,10 @@ const SSTV = (function() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
|
} else if (data.type === 'sstv_scope') {
|
||||||
|
sstvScopeTargetRms = data.rms;
|
||||||
|
sstvScopeTargetPeak = data.peak;
|
||||||
|
if (data.tone !== undefined) sstvScopeTone = data.tone;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
@@ -660,6 +822,9 @@ const SSTV = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
stopSstvScope();
|
||||||
|
const scopePanel = document.getElementById('sstvScopePanel');
|
||||||
|
if (scopePanel) scopePanel.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -680,8 +845,97 @@ const SSTV = (function() {
|
|||||||
renderGallery();
|
renderGallery();
|
||||||
showNotification('SSTV', 'New image decoded!');
|
showNotification('SSTV', 'New image decoded!');
|
||||||
updateStatusUI('listening', 'Listening...');
|
updateStatusUI('listening', 'Listening...');
|
||||||
|
sstvScopeMsgBurst = 1.0;
|
||||||
|
// Clear decode progress so signal monitor can take over
|
||||||
|
const liveContent = document.getElementById('sstvLiveContent');
|
||||||
|
if (liveContent) liveContent.innerHTML = '';
|
||||||
} else if (data.status === 'detecting') {
|
} else if (data.status === 'detecting') {
|
||||||
|
// Ignore detecting events if currently decoding (e.g. Doppler updates)
|
||||||
|
const dot = document.getElementById('sstvStripDot');
|
||||||
|
if (dot && dot.classList.contains('decoding')) return;
|
||||||
|
|
||||||
updateStatusUI('listening', data.message || 'Listening...');
|
updateStatusUI('listening', data.message || 'Listening...');
|
||||||
|
if (data.signal_level !== undefined) {
|
||||||
|
renderSignalMonitor(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render signal monitor in live area during detecting mode
|
||||||
|
*/
|
||||||
|
function renderSignalMonitor(data) {
|
||||||
|
const container = document.getElementById('sstvLiveContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const level = data.signal_level || 0;
|
||||||
|
const tone = data.sstv_tone;
|
||||||
|
|
||||||
|
let barColor, statusText;
|
||||||
|
if (tone === 'leader') {
|
||||||
|
barColor = 'var(--accent-green)';
|
||||||
|
statusText = 'SSTV leader tone detected';
|
||||||
|
} else if (tone === 'sync') {
|
||||||
|
barColor = 'var(--accent-cyan)';
|
||||||
|
statusText = 'SSTV sync pulse detected';
|
||||||
|
} else if (tone === 'noise') {
|
||||||
|
barColor = 'var(--text-dim)';
|
||||||
|
statusText = 'Audio signal present';
|
||||||
|
} else if (level > 10) {
|
||||||
|
barColor = 'var(--text-dim)';
|
||||||
|
statusText = 'Audio signal present';
|
||||||
|
} else {
|
||||||
|
barColor = 'var(--text-dim)';
|
||||||
|
statusText = 'No signal';
|
||||||
|
}
|
||||||
|
|
||||||
|
let monitor = container.querySelector('.sstv-signal-monitor');
|
||||||
|
if (!monitor) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="sstv-signal-monitor">
|
||||||
|
<div class="sstv-signal-monitor-header">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
|
||||||
|
<circle cx="12" cy="18" r="2"/>
|
||||||
|
<path d="M12 16V12"/>
|
||||||
|
</svg>
|
||||||
|
Signal Monitor
|
||||||
|
</div>
|
||||||
|
<div class="sstv-signal-level-row">
|
||||||
|
<span class="sstv-signal-level-label">LEVEL</span>
|
||||||
|
<div class="sstv-signal-bar-track">
|
||||||
|
<div class="sstv-signal-bar-fill" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="sstv-signal-level-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-signal-status-text">No signal</div>
|
||||||
|
<div class="sstv-signal-vis-state">VIS: idle</div>
|
||||||
|
</div>`;
|
||||||
|
monitor = container.querySelector('.sstv-signal-monitor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fill = monitor.querySelector('.sstv-signal-bar-fill');
|
||||||
|
fill.style.width = level + '%';
|
||||||
|
fill.style.background = barColor;
|
||||||
|
monitor.querySelector('.sstv-signal-status-text').textContent = statusText;
|
||||||
|
monitor.querySelector('.sstv-signal-level-value').textContent = level;
|
||||||
|
|
||||||
|
const visStateEl = monitor.querySelector('.sstv-signal-vis-state');
|
||||||
|
if (visStateEl && data.vis_state) {
|
||||||
|
const stateLabels = {
|
||||||
|
'idle': 'Idle',
|
||||||
|
'leader_1': 'Leader',
|
||||||
|
'break': 'Break',
|
||||||
|
'leader_2': 'Leader 2',
|
||||||
|
'start_bit': 'Start bit',
|
||||||
|
'data_bits': 'Data bits',
|
||||||
|
'parity': 'Parity',
|
||||||
|
'stop_bit': 'Stop bit',
|
||||||
|
};
|
||||||
|
const label = stateLabels[data.vis_state] || data.vis_state;
|
||||||
|
visStateEl.textContent = 'VIS: ' + label;
|
||||||
|
visStateEl.className = 'sstv-signal-vis-state' +
|
||||||
|
(data.vis_state !== 'idle' ? ' active' : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,18 +946,33 @@ const SSTV = (function() {
|
|||||||
const liveContent = document.getElementById('sstvLiveContent');
|
const liveContent = document.getElementById('sstvLiveContent');
|
||||||
if (!liveContent) return;
|
if (!liveContent) return;
|
||||||
|
|
||||||
liveContent.innerHTML = `
|
let container = liveContent.querySelector('.sstv-decode-container');
|
||||||
<div class="sstv-canvas-container">
|
if (!container) {
|
||||||
<canvas id="sstvCanvas" width="320" height="256"></canvas>
|
liveContent.innerHTML = `
|
||||||
</div>
|
<div class="sstv-decode-container">
|
||||||
<div class="sstv-decode-info">
|
<div class="sstv-canvas-container">
|
||||||
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
|
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
|
||||||
<div class="sstv-progress-bar">
|
</div>
|
||||||
<div class="progress" style="width: ${data.progress || 0}%"></div>
|
<div class="sstv-decode-info">
|
||||||
|
<div class="sstv-mode-label"></div>
|
||||||
|
<div class="sstv-progress-bar">
|
||||||
|
<div class="progress" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sstv-status-message"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
|
`;
|
||||||
</div>
|
container = liveContent.querySelector('.sstv-decode-container');
|
||||||
`;
|
}
|
||||||
|
|
||||||
|
container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...';
|
||||||
|
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
|
||||||
|
container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...';
|
||||||
|
|
||||||
|
if (data.partial_image) {
|
||||||
|
const img = container.querySelector('#sstvDecodeImg');
|
||||||
|
if (img) img.src = data.partial_image;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -757,12 +1026,22 @@ const SSTV = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gallery.innerHTML = images.map(img => `
|
gallery.innerHTML = images.map(img => `
|
||||||
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
|
<div class="sstv-image-card">
|
||||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
<div class="sstv-image-card-inner" onclick="SSTV.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
|
||||||
|
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
||||||
|
</div>
|
||||||
<div class="sstv-image-info">
|
<div class="sstv-image-info">
|
||||||
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||||
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sstv-image-actions">
|
||||||
|
<button onclick="event.stopPropagation(); SSTV.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); SSTV.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -894,19 +1173,45 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Show full-size image in modal
|
* Show full-size image in modal
|
||||||
*/
|
*/
|
||||||
function showImage(url) {
|
let currentModalUrl = null;
|
||||||
|
let currentModalFilename = null;
|
||||||
|
|
||||||
|
function showImage(url, filename) {
|
||||||
|
currentModalUrl = url;
|
||||||
|
currentModalFilename = filename || null;
|
||||||
|
|
||||||
let modal = document.getElementById('sstvImageModal');
|
let modal = document.getElementById('sstvImageModal');
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
modal = document.createElement('div');
|
modal = document.createElement('div');
|
||||||
modal.id = 'sstvImageModal';
|
modal.id = 'sstvImageModal';
|
||||||
modal.className = 'sstv-image-modal';
|
modal.className = 'sstv-image-modal';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
|
<div class="sstv-modal-toolbar">
|
||||||
|
<button class="sstv-modal-btn" id="sstvModalDownload" title="Download">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button class="sstv-modal-btn delete" id="sstvModalDelete" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button class="sstv-modal-close" onclick="SSTV.closeImage()">×</button>
|
<button class="sstv-modal-close" onclick="SSTV.closeImage()">×</button>
|
||||||
<img src="" alt="SSTV Image">
|
<img src="" alt="SSTV Image">
|
||||||
`;
|
`;
|
||||||
modal.addEventListener('click', (e) => {
|
modal.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) closeImage();
|
if (e.target === modal) closeImage();
|
||||||
});
|
});
|
||||||
|
modal.querySelector('#sstvModalDownload').addEventListener('click', () => {
|
||||||
|
if (currentModalUrl && currentModalFilename) {
|
||||||
|
downloadImage(currentModalUrl, currentModalFilename);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modal.querySelector('#sstvModalDelete').addEventListener('click', () => {
|
||||||
|
if (currentModalFilename) {
|
||||||
|
deleteImage(currentModalFilename);
|
||||||
|
}
|
||||||
|
});
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,6 +1250,55 @@ const SSTV = (function() {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single image
|
||||||
|
*/
|
||||||
|
async function deleteImage(filename) {
|
||||||
|
if (!confirm('Delete this image?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
images = images.filter(img => img.filename !== filename);
|
||||||
|
updateImageCount(images.length);
|
||||||
|
renderGallery();
|
||||||
|
closeImage();
|
||||||
|
showNotification('SSTV', 'Image deleted');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete image:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all images
|
||||||
|
*/
|
||||||
|
async function deleteAllImages() {
|
||||||
|
if (!confirm('Delete all decoded images?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/sstv/images', { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
images = [];
|
||||||
|
updateImageCount(0);
|
||||||
|
renderGallery();
|
||||||
|
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete images:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an image
|
||||||
|
*/
|
||||||
|
function downloadImage(url, filename) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url + '/download';
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show status message
|
* Show status message
|
||||||
*/
|
*/
|
||||||
@@ -965,6 +1319,9 @@ const SSTV = (function() {
|
|||||||
loadIssSchedule,
|
loadIssSchedule,
|
||||||
showImage,
|
showImage,
|
||||||
closeImage,
|
closeImage,
|
||||||
|
deleteImage,
|
||||||
|
deleteAllImages,
|
||||||
|
downloadImage,
|
||||||
useGPS,
|
useGPS,
|
||||||
updateTLE,
|
updateTLE,
|
||||||
stopIssTracking,
|
stopIssTracking,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -38,10 +38,17 @@ function initWebSDR() {
|
|||||||
const mapEl = document.getElementById('websdrMap');
|
const mapEl = document.getElementById('websdrMap');
|
||||||
if (!mapEl || typeof L === 'undefined') return;
|
if (!mapEl || typeof L === 'undefined') return;
|
||||||
|
|
||||||
|
// Calculate minimum zoom so tiles fill the container vertically
|
||||||
|
const mapHeight = mapEl.clientHeight || 500;
|
||||||
|
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
||||||
|
|
||||||
websdrMap = L.map('websdrMap', {
|
websdrMap = L.map('websdrMap', {
|
||||||
center: [30, 0],
|
center: [20, 0],
|
||||||
zoom: 2,
|
zoom: Math.max(minZoom, 2),
|
||||||
|
minZoom: Math.max(minZoom, 2),
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
|
maxBounds: [[-85, -360], [85, 360]],
|
||||||
|
maxBoundsViscosity: 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
@@ -50,6 +57,9 @@ function initWebSDR() {
|
|||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
}).addTo(websdrMap);
|
}).addTo(websdrMap);
|
||||||
|
|
||||||
|
// Match background to tile ocean color so any remaining edge is seamless
|
||||||
|
mapEl.style.background = '#1a1d29';
|
||||||
|
|
||||||
websdrInitialized = true;
|
websdrInitialized = true;
|
||||||
|
|
||||||
if (!websdrSpyStationsLoaded) {
|
if (!websdrSpyStationsLoaded) {
|
||||||
@@ -82,8 +92,6 @@ function searchReceivers(refresh) {
|
|||||||
|
|
||||||
const countEl = document.getElementById('websdrReceiverCount');
|
const countEl = document.getElementById('websdrReceiverCount');
|
||||||
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
|
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
|
||||||
const sidebarCount = document.getElementById('websdrSidebarCount');
|
|
||||||
if (sidebarCount) sidebarCount.textContent = websdrReceivers.length;
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[WEBSDR] Search error:', err));
|
.catch(err => console.error('[WEBSDR] Search error:', err));
|
||||||
|
|||||||
+69
-33
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
|
|||||||
maxProbes: 1000,
|
maxProbes: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Agent Support
|
// Agent Support
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the API base URL, routing through agent proxy if agent is selected.
|
* Get the API base URL, routing through agent proxy if agent is selected.
|
||||||
@@ -59,15 +59,49 @@ const WiFiMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Check for agent mode conflicts before starting WiFi scan.
|
* Check for agent mode conflicts before starting WiFi scan.
|
||||||
*/
|
*/
|
||||||
function checkAgentConflicts() {
|
function checkAgentConflicts() {
|
||||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (typeof checkAgentModeConflict === 'function') {
|
if (typeof checkAgentModeConflict === 'function') {
|
||||||
return checkAgentModeConflict('wifi');
|
return checkAgentModeConflict('wifi');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChannelPresetList(preset) {
|
||||||
|
switch (preset) {
|
||||||
|
case '2.4-common':
|
||||||
|
return '1,6,11';
|
||||||
|
case '2.4-all':
|
||||||
|
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||||
|
case '5-low':
|
||||||
|
return '36,40,44,48';
|
||||||
|
case '5-mid':
|
||||||
|
return '52,56,60,64';
|
||||||
|
case '5-high':
|
||||||
|
return '149,153,157,161,165';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelConfig() {
|
||||||
|
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||||
|
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||||
|
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||||
|
|
||||||
|
const listValue = listInput.trim();
|
||||||
|
const presetValue = getChannelPresetList(preset);
|
||||||
|
|
||||||
|
const channels = listValue || presetValue || '';
|
||||||
|
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: channels || null,
|
||||||
|
channel: Number.isFinite(channel) ? channel : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// State
|
// State
|
||||||
@@ -461,10 +495,10 @@ const WiFiMode = (function() {
|
|||||||
setScanning(true, 'deep');
|
setScanning(true, 'deep');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const iface = elements.interfaceSelect?.value || null;
|
const iface = elements.interfaceSelect?.value || null;
|
||||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||||
const channel = document.getElementById('wifiChannel')?.value || null;
|
const channelConfig = buildChannelConfig();
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
@@ -473,23 +507,25 @@ const WiFiMode = (function() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interface: iface,
|
interface: iface,
|
||||||
scan_type: 'deep',
|
scan_type: 'deep',
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
channel: channel ? parseInt(channel) : null,
|
channel: channelConfig.channel,
|
||||||
}),
|
channels: channelConfig.channels,
|
||||||
});
|
}),
|
||||||
} else {
|
});
|
||||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
} else {
|
||||||
method: 'POST',
|
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
headers: { 'Content-Type': 'application/json' },
|
||||||
interface: iface,
|
body: JSON.stringify({
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
interface: iface,
|
||||||
channel: channel ? parseInt(channel) : null,
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
}),
|
channel: channelConfig.channel,
|
||||||
});
|
channels: channelConfig.channels,
|
||||||
}
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
(c) 2014, Vladimir Agafonkin
|
||||||
|
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
|
||||||
|
https://github.com/mourner/simpleheat
|
||||||
|
*/
|
||||||
|
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
|
||||||
|
(c) 2014, Vladimir Agafonkin
|
||||||
|
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
|
||||||
|
https://github.com/Leaflet/Leaflet.heat
|
||||||
|
*/
|
||||||
|
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
|
||||||
@@ -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
|
||||||
|
|||||||
+1307
-76
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">
|
||||||
@@ -61,7 +59,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;">
|
||||||
|
|||||||
@@ -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,28 +22,59 @@
|
|||||||
<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">
|
||||||
<label>Gain</label>
|
<label>Gain</label>
|
||||||
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
|
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PPM Correction</label>
|
||||||
|
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
|
||||||
|
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 4px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||||
|
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
|
||||||
|
<span>Relax CRC (weak signals)</span>
|
||||||
|
</label>
|
||||||
|
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
|
||||||
|
Allows more frames through on marginal signals at the cost of occasional errors
|
||||||
|
</span>
|
||||||
|
</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;">
|
||||||
@@ -68,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>
|
||||||
|
|||||||
@@ -61,35 +61,8 @@
|
|||||||
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
||||||
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
||||||
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
||||||
|
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Waterfall Controls -->
|
|
||||||
<div class="section">
|
|
||||||
<h3>Waterfall</h3>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Start (MHz)</label>
|
|
||||||
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">End (MHz)</label>
|
|
||||||
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Bin Size</label>
|
|
||||||
<select id="waterfallBinSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<option value="5000">5 kHz</option>
|
|
||||||
<option value="10000" selected>10 kHz</option>
|
|
||||||
<option value="25000">25 kHz</option>
|
|
||||||
<option value="100000">100 kHz</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 8px;">
|
|
||||||
<label style="font-size: 10px;">Gain</label>
|
|
||||||
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
|
|
||||||
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
|
|
||||||
</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>
|
||||||
@@ -67,12 +67,4 @@
|
|||||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">Loading...</div>
|
<div style="color: var(--text-muted); text-align: center; padding: 10px;">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Receiver Count -->
|
|
||||||
<div class="section" style="margin-top: 12px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Receivers</span>
|
|
||||||
<span id="websdrSidebarCount" style="font-size: 11px; color: var(--accent-cyan);">0</span>
|
|
||||||
</div>
|
|
||||||
</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
|
||||||
@@ -69,7 +70,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Channel (empty = hop)</label>
|
<label>Channel Preset</label>
|
||||||
|
<select id="wifiChannelPreset">
|
||||||
|
<option value="">Auto hop (all)</option>
|
||||||
|
<option value="2.4-common">2.4 GHz Common (1,6,11)</option>
|
||||||
|
<option value="2.4-all">2.4 GHz All (1-13)</option>
|
||||||
|
<option value="5-low">5 GHz Low (36-48)</option>
|
||||||
|
<option value="5-mid">5 GHz Mid/DFS (52-64)</option>
|
||||||
|
<option value="5-high">5 GHz High (149-165)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Channel List (overrides preset)</label>
|
||||||
|
<input type="text" id="wifiChannelList" placeholder="e.g., 1,6,11 or 36,40,44,48">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Channel (single)</label>
|
||||||
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,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;">
|
||||||
@@ -186,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>
|
||||||
|
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
{{ 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>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,12 +186,13 @@
|
|||||||
{{ 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('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 #}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
||||||
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
||||||
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
||||||
|
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
|
||||||
|
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
|
||||||
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,6 +282,82 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts Section -->
|
||||||
|
<div id="settings-alerts" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
||||||
|
<div id="alertsFeedList" class="settings-feed">
|
||||||
|
<div class="settings-feed-empty">No alerts yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Quick Rules</div>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button class="check-assets-btn" onclick="AlertCenter.enableTrackerAlerts()">Enable Tracker Alerts</button>
|
||||||
|
<button class="check-assets-btn" onclick="AlertCenter.disableTrackerAlerts()">Disable Tracker Alerts</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-info" style="margin-top: 10px;">
|
||||||
|
Use Bluetooth device details to add specific device watchlist alerts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording Section -->
|
||||||
|
<div id="settings-recording" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Start Recording</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Mode</span>
|
||||||
|
<span class="settings-label-desc">Record live events for a mode</span>
|
||||||
|
</div>
|
||||||
|
<select id="recordingModeSelect" class="settings-select" style="width: 200px;">
|
||||||
|
<option value="pager">Pager</option>
|
||||||
|
<option value="sensor">433 Sensors</option>
|
||||||
|
<option value="wifi">WiFi</option>
|
||||||
|
<option value="bluetooth">Bluetooth</option>
|
||||||
|
<option value="adsb">ADS-B</option>
|
||||||
|
<option value="ais">AIS</option>
|
||||||
|
<option value="dsc">DSC</option>
|
||||||
|
<option value="acars">ACARS</option>
|
||||||
|
<option value="aprs">APRS</option>
|
||||||
|
<option value="rtlamr">RTLAMR</option>
|
||||||
|
<option value="tscm">TSCM</option>
|
||||||
|
<option value="sstv">SSTV</option>
|
||||||
|
<option value="sstv_general">SSTV General</option>
|
||||||
|
<option value="listening_scanner">Listening Post</option>
|
||||||
|
<option value="waterfall">Waterfall</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Label</span>
|
||||||
|
<span class="settings-label-desc">Optional note for the session</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="recordingLabelInput" class="settings-input" placeholder="Morning sweep" style="width: 200px;">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||||
|
<button class="check-assets-btn" onclick="RecordingUI.start()">Start</button>
|
||||||
|
<button class="check-assets-btn" onclick="RecordingUI.stop()">Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Active Sessions</div>
|
||||||
|
<div id="recordingActiveList" class="settings-feed">
|
||||||
|
<div class="settings-feed-empty">No active recordings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Recent Recordings</div>
|
||||||
|
<div id="recordingList" class="settings-feed">
|
||||||
|
<div class="settings-feed-empty">No recordings yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- About Section -->
|
<!-- About Section -->
|
||||||
<div id="settings-about" class="settings-section">
|
<div id="settings-about" class="settings-section">
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -275,12 +271,8 @@
|
|||||||
|
|
||||||
const satellites = {
|
const 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' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function onSatelliteChange() {
|
function onSatelliteChange() {
|
||||||
|
|||||||
+172
-6
@@ -1,8 +1,10 @@
|
|||||||
"""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
|
||||||
from routes.dmr import parse_dsd_output
|
import routes.dmr as dmr_module
|
||||||
|
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -57,6 +59,43 @@ def test_parse_nac():
|
|||||||
assert result['nac'] == '293'
|
assert result['nac'] == '293'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_talkgroup_dsd_fme_format():
|
||||||
|
"""Should parse dsd-fme comma-separated TG/Src format."""
|
||||||
|
result = parse_dsd_output('TG: 12345, Src: 67890')
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'call'
|
||||||
|
assert result['talkgroup'] == 12345
|
||||||
|
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():
|
||||||
|
"""TG line with slot info should capture both."""
|
||||||
|
result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200')
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'call'
|
||||||
|
assert result['talkgroup'] == 100
|
||||||
|
assert result['source_id'] == 200
|
||||||
|
assert result['slot'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_voice_with_slot():
|
||||||
|
"""Voice frame with slot info should be voice, not slot."""
|
||||||
|
result = parse_dsd_output('Slot 2 Voice Frame')
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'voice'
|
||||||
|
assert result['slot'] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_parse_empty_line():
|
def test_parse_empty_line():
|
||||||
"""Empty lines should return None."""
|
"""Empty lines should return None."""
|
||||||
assert parse_dsd_output('') is None
|
assert parse_dsd_output('') is None
|
||||||
@@ -64,8 +103,63 @@ def test_parse_empty_line():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_unrecognized():
|
def test_parse_unrecognized():
|
||||||
"""Unrecognized lines should return None."""
|
"""Unrecognized lines should return raw event for diagnostics."""
|
||||||
assert parse_dsd_output('some random text') is None
|
result = parse_dsd_output('some random text')
|
||||||
|
assert result is not None
|
||||||
|
assert result['type'] == 'raw'
|
||||||
|
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():
|
||||||
|
"""dsd-fme remapped several flags; tables must NOT be identical."""
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsd_fme_protocol_flags_known_values():
|
||||||
|
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsd_protocol_flags_known_values():
|
||||||
|
"""Classic DSD protocol flags should map to the correct -f flags."""
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['dstar'] == ['-fi']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['provoice'] == ['-fv']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['auto'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsd_fme_modulation_hints():
|
||||||
|
"""C4FM modulation hints should be set for C4FM protocols."""
|
||||||
|
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
|
||||||
|
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
|
||||||
|
# P25, D-Star and ProVoice should not have forced modulation
|
||||||
|
assert 'p25' not in _DSD_FME_MODULATION
|
||||||
|
assert 'dstar' not in _DSD_FME_MODULATION
|
||||||
|
assert 'provoice' not in _DSD_FME_MODULATION
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -80,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')
|
||||||
@@ -100,7 +228,7 @@ def test_dmr_status(auth_client):
|
|||||||
|
|
||||||
def test_dmr_start_no_dsd(auth_client):
|
def test_dmr_start_no_dsd(auth_client):
|
||||||
"""Start should fail gracefully when dsd is not installed."""
|
"""Start should fail gracefully when dsd is not installed."""
|
||||||
with patch('routes.dmr.find_dsd', return_value=None):
|
with patch('routes.dmr.find_dsd', return_value=(None, False)):
|
||||||
resp = auth_client.post('/dmr/start', json={
|
resp = auth_client.post('/dmr/start', json={
|
||||||
'frequency': 462.5625,
|
'frequency': 462.5625,
|
||||||
'protocol': 'auto',
|
'protocol': 'auto',
|
||||||
@@ -112,7 +240,7 @@ def test_dmr_start_no_dsd(auth_client):
|
|||||||
|
|
||||||
def test_dmr_start_no_rtl_fm(auth_client):
|
def test_dmr_start_no_rtl_fm(auth_client):
|
||||||
"""Start should fail when rtl_fm is missing."""
|
"""Start should fail when rtl_fm is missing."""
|
||||||
with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \
|
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
|
||||||
patch('routes.dmr.find_rtl_fm', return_value=None):
|
patch('routes.dmr.find_rtl_fm', return_value=None):
|
||||||
resp = auth_client.post('/dmr/start', json={
|
resp = auth_client.post('/dmr/start', json={
|
||||||
'frequency': 462.5625,
|
'frequency': 462.5625,
|
||||||
@@ -122,7 +250,7 @@ def test_dmr_start_no_rtl_fm(auth_client):
|
|||||||
|
|
||||||
def test_dmr_start_invalid_protocol(auth_client):
|
def test_dmr_start_invalid_protocol(auth_client):
|
||||||
"""Start should reject invalid protocol."""
|
"""Start should reject invalid protocol."""
|
||||||
with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \
|
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_rtl_fm', return_value='/usr/bin/rtl_fm'):
|
||||||
resp = auth_client.post('/dmr/start', json={
|
resp = auth_client.post('/dmr/start', json={
|
||||||
'frequency': 462.5625,
|
'frequency': 462.5625,
|
||||||
@@ -143,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
|
||||||
|
|||||||
+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,798 @@
|
|||||||
|
"""Tests for the pure-Python SSTV decoder.
|
||||||
|
|
||||||
|
Covers VIS detection, Goertzel accuracy, mode specs, synthetic image
|
||||||
|
decoding, and integration with the SSTVDecoder orchestrator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.sstv.constants import (
|
||||||
|
FREQ_BLACK,
|
||||||
|
FREQ_LEADER,
|
||||||
|
FREQ_PIXEL_HIGH,
|
||||||
|
FREQ_PIXEL_LOW,
|
||||||
|
FREQ_SYNC,
|
||||||
|
FREQ_VIS_BIT_0,
|
||||||
|
FREQ_VIS_BIT_1,
|
||||||
|
FREQ_WHITE,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
)
|
||||||
|
from utils.sstv.dsp import (
|
||||||
|
estimate_frequency,
|
||||||
|
freq_to_pixel,
|
||||||
|
goertzel,
|
||||||
|
goertzel_batch,
|
||||||
|
goertzel_mag,
|
||||||
|
normalize_audio,
|
||||||
|
samples_for_duration,
|
||||||
|
)
|
||||||
|
from utils.sstv.modes import (
|
||||||
|
ALL_MODES,
|
||||||
|
MARTIN_1,
|
||||||
|
PD_120,
|
||||||
|
PD_180,
|
||||||
|
ROBOT_36,
|
||||||
|
ROBOT_72,
|
||||||
|
SCOTTIE_1,
|
||||||
|
ColorModel,
|
||||||
|
SyncPosition,
|
||||||
|
get_mode,
|
||||||
|
get_mode_by_name,
|
||||||
|
)
|
||||||
|
from utils.sstv.sstv_decoder import (
|
||||||
|
DecodeProgress,
|
||||||
|
DopplerInfo,
|
||||||
|
SSTVDecoder,
|
||||||
|
SSTVImage,
|
||||||
|
get_sstv_decoder,
|
||||||
|
is_sstv_available,
|
||||||
|
)
|
||||||
|
from utils.sstv.vis import VISDetector, VISState
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def generate_tone(freq: float, duration_s: float,
|
||||||
|
sample_rate: int = SAMPLE_RATE,
|
||||||
|
amplitude: float = 0.8) -> np.ndarray:
|
||||||
|
"""Generate a pure sine tone."""
|
||||||
|
t = np.arange(int(duration_s * sample_rate)) / sample_rate
|
||||||
|
return amplitude * np.sin(2 * np.pi * freq * t)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_vis_header(vis_code: int, sample_rate: int = SAMPLE_RATE) -> np.ndarray:
|
||||||
|
"""Generate a synthetic VIS header for a given code.
|
||||||
|
|
||||||
|
Structure: leader1 (300ms) + break (10ms) + leader2 (300ms)
|
||||||
|
+ start_bit (30ms) + 8 data bits (30ms each)
|
||||||
|
+ parity bit (30ms) + stop_bit (30ms)
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Leader 1 (1900 Hz, 300ms)
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.300, sample_rate))
|
||||||
|
|
||||||
|
# Break (1200 Hz, 10ms)
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.010, sample_rate))
|
||||||
|
|
||||||
|
# Leader 2 (1900 Hz, 300ms)
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.300, sample_rate))
|
||||||
|
|
||||||
|
# Start bit (1200 Hz, 30ms)
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030, sample_rate))
|
||||||
|
|
||||||
|
# 8 data bits (LSB first)
|
||||||
|
ones_count = 0
|
||||||
|
for i in range(8):
|
||||||
|
bit = (vis_code >> i) & 1
|
||||||
|
if bit:
|
||||||
|
ones_count += 1
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030, sample_rate))
|
||||||
|
else:
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030, sample_rate))
|
||||||
|
|
||||||
|
# Even parity bit
|
||||||
|
parity = ones_count % 2
|
||||||
|
if parity:
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030, sample_rate))
|
||||||
|
else:
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030, sample_rate))
|
||||||
|
|
||||||
|
# Stop bit (1200 Hz, 30ms)
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030, sample_rate))
|
||||||
|
|
||||||
|
return np.concatenate(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Goertzel / DSP tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGoertzel:
|
||||||
|
"""Tests for the Goertzel algorithm."""
|
||||||
|
|
||||||
|
def test_detects_exact_frequency(self):
|
||||||
|
"""Goertzel should have peak energy at the generated frequency."""
|
||||||
|
tone = generate_tone(1200.0, 0.01)
|
||||||
|
energy_1200 = goertzel(tone, 1200.0)
|
||||||
|
energy_1500 = goertzel(tone, 1500.0)
|
||||||
|
energy_1900 = goertzel(tone, 1900.0)
|
||||||
|
|
||||||
|
assert energy_1200 > energy_1500 * 5
|
||||||
|
assert energy_1200 > energy_1900 * 5
|
||||||
|
|
||||||
|
def test_different_frequencies(self):
|
||||||
|
"""Each candidate frequency should produce peak at its own freq."""
|
||||||
|
for freq in [1100, 1200, 1300, 1500, 1900, 2300]:
|
||||||
|
tone = generate_tone(float(freq), 0.01)
|
||||||
|
energy = goertzel(tone, float(freq))
|
||||||
|
# Should have significant energy at the target
|
||||||
|
assert energy > 0
|
||||||
|
|
||||||
|
def test_empty_samples(self):
|
||||||
|
"""Goertzel on empty array should return 0."""
|
||||||
|
assert goertzel(np.array([], dtype=np.float64), 1200.0) == 0.0
|
||||||
|
|
||||||
|
def test_goertzel_mag(self):
|
||||||
|
"""goertzel_mag should return sqrt of energy."""
|
||||||
|
tone = generate_tone(1200.0, 0.01)
|
||||||
|
energy = goertzel(tone, 1200.0)
|
||||||
|
mag = goertzel_mag(tone, 1200.0)
|
||||||
|
assert abs(mag - math.sqrt(energy)) < 1e-10
|
||||||
|
|
||||||
|
|
||||||
|
class TestEstimateFrequency:
|
||||||
|
"""Tests for frequency estimation."""
|
||||||
|
|
||||||
|
def test_estimates_known_frequency(self):
|
||||||
|
"""Should accurately estimate a known tone frequency."""
|
||||||
|
tone = generate_tone(1900.0, 0.02)
|
||||||
|
estimated = estimate_frequency(tone, 1000.0, 2500.0)
|
||||||
|
assert abs(estimated - 1900.0) <= 30.0
|
||||||
|
|
||||||
|
def test_estimates_black_level(self):
|
||||||
|
"""Should detect the black level frequency."""
|
||||||
|
tone = generate_tone(FREQ_BLACK, 0.02)
|
||||||
|
estimated = estimate_frequency(tone, 1400.0, 1600.0)
|
||||||
|
assert abs(estimated - FREQ_BLACK) <= 30.0
|
||||||
|
|
||||||
|
def test_estimates_white_level(self):
|
||||||
|
"""Should detect the white level frequency."""
|
||||||
|
tone = generate_tone(FREQ_WHITE, 0.02)
|
||||||
|
estimated = estimate_frequency(tone, 2200.0, 2400.0)
|
||||||
|
assert abs(estimated - FREQ_WHITE) <= 30.0
|
||||||
|
|
||||||
|
def test_empty_samples(self):
|
||||||
|
"""Should return 0 for empty input."""
|
||||||
|
assert estimate_frequency(np.array([], dtype=np.float64)) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreqToPixel:
|
||||||
|
"""Tests for frequency-to-pixel mapping."""
|
||||||
|
|
||||||
|
def test_black_level(self):
|
||||||
|
"""1500 Hz should map to 0 (black)."""
|
||||||
|
assert freq_to_pixel(FREQ_PIXEL_LOW) == 0
|
||||||
|
|
||||||
|
def test_white_level(self):
|
||||||
|
"""2300 Hz should map to 255 (white)."""
|
||||||
|
assert freq_to_pixel(FREQ_PIXEL_HIGH) == 255
|
||||||
|
|
||||||
|
def test_midpoint(self):
|
||||||
|
"""Middle frequency should map to approximately 128."""
|
||||||
|
mid_freq = (FREQ_PIXEL_LOW + FREQ_PIXEL_HIGH) / 2
|
||||||
|
pixel = freq_to_pixel(mid_freq)
|
||||||
|
assert 120 <= pixel <= 135
|
||||||
|
|
||||||
|
def test_below_black_clamps(self):
|
||||||
|
"""Frequencies below black level should clamp to 0."""
|
||||||
|
assert freq_to_pixel(1000.0) == 0
|
||||||
|
|
||||||
|
def test_above_white_clamps(self):
|
||||||
|
"""Frequencies above white level should clamp to 255."""
|
||||||
|
assert freq_to_pixel(3000.0) == 255
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeAudio:
|
||||||
|
"""Tests for int16 to float64 normalization."""
|
||||||
|
|
||||||
|
def test_max_positive(self):
|
||||||
|
"""int16 max should normalize to ~1.0."""
|
||||||
|
raw = np.array([32767], dtype=np.int16)
|
||||||
|
result = normalize_audio(raw)
|
||||||
|
assert abs(result[0] - (32767.0 / 32768.0)) < 1e-10
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
"""int16 zero should normalize to 0.0."""
|
||||||
|
raw = np.array([0], dtype=np.int16)
|
||||||
|
result = normalize_audio(raw)
|
||||||
|
assert result[0] == 0.0
|
||||||
|
|
||||||
|
def test_negative(self):
|
||||||
|
"""int16 min should normalize to -1.0."""
|
||||||
|
raw = np.array([-32768], dtype=np.int16)
|
||||||
|
result = normalize_audio(raw)
|
||||||
|
assert result[0] == -1.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSamplesForDuration:
|
||||||
|
"""Tests for duration-to-samples calculation."""
|
||||||
|
|
||||||
|
def test_one_second(self):
|
||||||
|
"""1 second at 48kHz should be 48000 samples."""
|
||||||
|
assert samples_for_duration(1.0) == 48000
|
||||||
|
|
||||||
|
def test_five_ms(self):
|
||||||
|
"""5ms at 48kHz should be 240 samples."""
|
||||||
|
assert samples_for_duration(0.005) == 240
|
||||||
|
|
||||||
|
def test_custom_rate(self):
|
||||||
|
"""Should work with custom sample rates."""
|
||||||
|
assert samples_for_duration(1.0, 22050) == 22050
|
||||||
|
|
||||||
|
|
||||||
|
class TestGoertzelBatch:
|
||||||
|
"""Tests for the vectorized batch Goertzel function."""
|
||||||
|
|
||||||
|
def test_matches_scalar_goertzel(self):
|
||||||
|
"""Batch result should match individual goertzel calls."""
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
# 10 pixel windows of 20 samples each
|
||||||
|
audio_matrix = rng.standard_normal((10, 20))
|
||||||
|
freqs = np.array([1200.0, 1500.0, 1900.0, 2300.0])
|
||||||
|
|
||||||
|
batch_result = goertzel_batch(audio_matrix, freqs)
|
||||||
|
assert batch_result.shape == (10, 4)
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
for j, f in enumerate(freqs):
|
||||||
|
scalar = goertzel(audio_matrix[i], f)
|
||||||
|
assert abs(batch_result[i, j] - scalar) < 1e-6, \
|
||||||
|
f"Mismatch at pixel {i}, freq {f}"
|
||||||
|
|
||||||
|
def test_detects_correct_frequency(self):
|
||||||
|
"""Batch should find peak at the correct frequency for each pixel.
|
||||||
|
|
||||||
|
Uses 96-sample windows (2ms at 48kHz) matching the decoder's
|
||||||
|
minimum analysis window, with 5Hz resolution.
|
||||||
|
"""
|
||||||
|
freqs = np.arange(1400.0, 2405.0, 5.0) # 5Hz step, same as decoder
|
||||||
|
window_size = 96 # Matches _MIN_ANALYSIS_WINDOW
|
||||||
|
pixels = []
|
||||||
|
for target in [1500.0, 1900.0, 2300.0]:
|
||||||
|
t = np.arange(window_size) / SAMPLE_RATE
|
||||||
|
pixels.append(0.8 * np.sin(2 * np.pi * target * t))
|
||||||
|
audio_matrix = np.array(pixels)
|
||||||
|
|
||||||
|
energies = goertzel_batch(audio_matrix, freqs)
|
||||||
|
best_idx = np.argmax(energies, axis=1)
|
||||||
|
best_freqs = freqs[best_idx]
|
||||||
|
|
||||||
|
# With 96 samples, frequency accuracy is within ~25 Hz
|
||||||
|
assert abs(best_freqs[0] - 1500.0) <= 30.0
|
||||||
|
assert abs(best_freqs[1] - 1900.0) <= 30.0
|
||||||
|
assert abs(best_freqs[2] - 2300.0) <= 30.0
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
"""Should handle empty inputs gracefully."""
|
||||||
|
result = goertzel_batch(np.zeros((0, 10)), np.array([1200.0]))
|
||||||
|
assert result.shape == (0, 1)
|
||||||
|
|
||||||
|
result = goertzel_batch(np.zeros((5, 10)), np.array([]))
|
||||||
|
assert result.shape == (5, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VIS detection tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestVISDetector:
|
||||||
|
"""Tests for VIS header detection."""
|
||||||
|
|
||||||
|
def test_initial_state(self):
|
||||||
|
"""Detector should start in IDLE state."""
|
||||||
|
detector = VISDetector()
|
||||||
|
assert detector.state == VISState.IDLE
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
"""Reset should return to IDLE state."""
|
||||||
|
detector = VISDetector()
|
||||||
|
# Feed some leader tone to change state
|
||||||
|
detector.feed(generate_tone(FREQ_LEADER, 0.250))
|
||||||
|
detector.reset()
|
||||||
|
assert detector.state == VISState.IDLE
|
||||||
|
|
||||||
|
def test_detect_robot36(self):
|
||||||
|
"""Should detect Robot36 VIS code (8)."""
|
||||||
|
detector = VISDetector()
|
||||||
|
header = generate_vis_header(8) # Robot36
|
||||||
|
# Add some silence before and after
|
||||||
|
audio = np.concatenate([
|
||||||
|
np.zeros(2400),
|
||||||
|
header,
|
||||||
|
np.zeros(2400),
|
||||||
|
])
|
||||||
|
|
||||||
|
result = detector.feed(audio)
|
||||||
|
assert result is not None
|
||||||
|
vis_code, mode_name = result
|
||||||
|
assert vis_code == 8
|
||||||
|
assert mode_name == 'Robot36'
|
||||||
|
|
||||||
|
def test_detect_martin1(self):
|
||||||
|
"""Should detect Martin1 VIS code (44)."""
|
||||||
|
detector = VISDetector()
|
||||||
|
header = generate_vis_header(44) # Martin1
|
||||||
|
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
|
||||||
|
|
||||||
|
result = detector.feed(audio)
|
||||||
|
assert result is not None
|
||||||
|
vis_code, mode_name = result
|
||||||
|
assert vis_code == 44
|
||||||
|
assert mode_name == 'Martin1'
|
||||||
|
|
||||||
|
def test_detect_scottie1(self):
|
||||||
|
"""Should detect Scottie1 VIS code (60)."""
|
||||||
|
detector = VISDetector()
|
||||||
|
header = generate_vis_header(60) # Scottie1
|
||||||
|
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
|
||||||
|
|
||||||
|
result = detector.feed(audio)
|
||||||
|
assert result is not None
|
||||||
|
vis_code, mode_name = result
|
||||||
|
assert vis_code == 60
|
||||||
|
assert mode_name == 'Scottie1'
|
||||||
|
|
||||||
|
def test_detect_pd120(self):
|
||||||
|
"""Should detect PD120 VIS code (93)."""
|
||||||
|
detector = VISDetector()
|
||||||
|
header = generate_vis_header(93) # PD120
|
||||||
|
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
|
||||||
|
|
||||||
|
result = detector.feed(audio)
|
||||||
|
assert result is not None
|
||||||
|
vis_code, mode_name = result
|
||||||
|
assert vis_code == 93
|
||||||
|
assert mode_name == 'PD120'
|
||||||
|
|
||||||
|
def test_noise_rejection(self):
|
||||||
|
"""Should not falsely detect VIS in noise."""
|
||||||
|
detector = VISDetector()
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
noise = rng.standard_normal(48000) * 0.1 # 1 second of noise
|
||||||
|
result = detector.feed(noise)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_incremental_feeding(self):
|
||||||
|
"""Should work with small chunks fed incrementally."""
|
||||||
|
detector = VISDetector()
|
||||||
|
header = generate_vis_header(8)
|
||||||
|
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
|
||||||
|
|
||||||
|
# Feed in small chunks (100 samples each)
|
||||||
|
chunk_size = 100
|
||||||
|
result = None
|
||||||
|
offset = 0
|
||||||
|
while offset < len(audio):
|
||||||
|
chunk = audio[offset:offset + chunk_size]
|
||||||
|
offset += chunk_size
|
||||||
|
result = detector.feed(chunk)
|
||||||
|
if result is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
vis_code, mode_name = result
|
||||||
|
assert vis_code == 8
|
||||||
|
assert mode_name == 'Robot36'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mode spec tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestModes:
|
||||||
|
"""Tests for SSTV mode specifications."""
|
||||||
|
|
||||||
|
def test_all_vis_codes_have_modes(self):
|
||||||
|
"""All defined VIS codes should have matching mode specs."""
|
||||||
|
for vis_code in [8, 12, 44, 40, 60, 56, 93, 95]:
|
||||||
|
mode = get_mode(vis_code)
|
||||||
|
assert mode is not None, f"No mode for VIS code {vis_code}"
|
||||||
|
|
||||||
|
def test_robot36_spec(self):
|
||||||
|
"""Robot36 should have correct dimensions and timing."""
|
||||||
|
assert ROBOT_36.width == 320
|
||||||
|
assert ROBOT_36.height == 240
|
||||||
|
assert ROBOT_36.vis_code == 8
|
||||||
|
assert ROBOT_36.color_model == ColorModel.YCRCB
|
||||||
|
assert ROBOT_36.has_half_rate_chroma is True
|
||||||
|
assert ROBOT_36.sync_position == SyncPosition.FRONT
|
||||||
|
|
||||||
|
def test_martin1_spec(self):
|
||||||
|
"""Martin1 should have correct dimensions."""
|
||||||
|
assert MARTIN_1.width == 320
|
||||||
|
assert MARTIN_1.height == 256
|
||||||
|
assert MARTIN_1.vis_code == 44
|
||||||
|
assert MARTIN_1.color_model == ColorModel.RGB
|
||||||
|
assert len(MARTIN_1.channels) == 3
|
||||||
|
|
||||||
|
def test_scottie1_spec(self):
|
||||||
|
"""Scottie1 should have middle sync position."""
|
||||||
|
assert SCOTTIE_1.sync_position == SyncPosition.MIDDLE
|
||||||
|
assert SCOTTIE_1.width == 320
|
||||||
|
assert SCOTTIE_1.height == 256
|
||||||
|
|
||||||
|
def test_pd120_spec(self):
|
||||||
|
"""PD120 should have dual-luminance YCrCb."""
|
||||||
|
assert PD_120.width == 640
|
||||||
|
assert PD_120.height == 496
|
||||||
|
assert PD_120.color_model == ColorModel.YCRCB_DUAL
|
||||||
|
assert len(PD_120.channels) == 4 # Y1, Cr, Cb, Y2
|
||||||
|
|
||||||
|
def test_get_mode_unknown(self):
|
||||||
|
"""Unknown VIS code should return None."""
|
||||||
|
assert get_mode(999) is None
|
||||||
|
|
||||||
|
def test_get_mode_by_name(self):
|
||||||
|
"""Should look up modes by name."""
|
||||||
|
mode = get_mode_by_name('Robot36')
|
||||||
|
assert mode is not None
|
||||||
|
assert mode.vis_code == 8
|
||||||
|
|
||||||
|
def test_mode_by_name_unknown(self):
|
||||||
|
"""Unknown mode name should return None."""
|
||||||
|
assert get_mode_by_name('FakeMode') is None
|
||||||
|
|
||||||
|
def test_robot72_spec(self):
|
||||||
|
"""Robot72 should have 3 channels and full-rate chroma."""
|
||||||
|
assert ROBOT_72.width == 320
|
||||||
|
assert ROBOT_72.height == 240
|
||||||
|
assert ROBOT_72.vis_code == 12
|
||||||
|
assert ROBOT_72.color_model == ColorModel.YCRCB
|
||||||
|
assert ROBOT_72.has_half_rate_chroma is False
|
||||||
|
assert len(ROBOT_72.channels) == 3 # Y, Cr, Cb
|
||||||
|
assert ROBOT_72.channel_separator_ms == 6.0
|
||||||
|
|
||||||
|
def test_robot36_separator(self):
|
||||||
|
"""Robot36 should have a 6ms separator between Y and chroma."""
|
||||||
|
assert ROBOT_36.channel_separator_ms == 6.0
|
||||||
|
assert ROBOT_36.has_half_rate_chroma is True
|
||||||
|
assert len(ROBOT_36.channels) == 2 # Y, alternating Cr/Cb
|
||||||
|
|
||||||
|
def test_pd120_channel_timings(self):
|
||||||
|
"""PD120 channel durations should sum to line_duration minus sync+porch."""
|
||||||
|
channel_sum = sum(ch.duration_ms for ch in PD_120.channels)
|
||||||
|
expected = PD_120.line_duration_ms - PD_120.sync_duration_ms - PD_120.sync_porch_ms
|
||||||
|
assert abs(channel_sum - expected) < 0.1, \
|
||||||
|
f"PD120 channels sum to {channel_sum}ms, expected {expected}ms"
|
||||||
|
|
||||||
|
def test_pd180_channel_timings(self):
|
||||||
|
"""PD180 channel durations should sum to line_duration minus sync+porch."""
|
||||||
|
channel_sum = sum(ch.duration_ms for ch in PD_180.channels)
|
||||||
|
expected = PD_180.line_duration_ms - PD_180.sync_duration_ms - PD_180.sync_porch_ms
|
||||||
|
assert abs(channel_sum - expected) < 0.1, \
|
||||||
|
f"PD180 channels sum to {channel_sum}ms, expected {expected}ms"
|
||||||
|
|
||||||
|
def test_robot36_timing_consistency(self):
|
||||||
|
"""Robot36 total channel + sync + porch + separator should equal line_duration."""
|
||||||
|
total = (ROBOT_36.sync_duration_ms + ROBOT_36.sync_porch_ms
|
||||||
|
+ sum(ch.duration_ms for ch in ROBOT_36.channels)
|
||||||
|
+ ROBOT_36.channel_separator_ms) # 1 separator for 2 channels
|
||||||
|
assert abs(total - ROBOT_36.line_duration_ms) < 0.1
|
||||||
|
|
||||||
|
def test_robot72_timing_consistency(self):
|
||||||
|
"""Robot72 total should equal line_duration."""
|
||||||
|
# 3 channels with 2 separators
|
||||||
|
total = (ROBOT_72.sync_duration_ms + ROBOT_72.sync_porch_ms
|
||||||
|
+ sum(ch.duration_ms for ch in ROBOT_72.channels)
|
||||||
|
+ ROBOT_72.channel_separator_ms * 2)
|
||||||
|
assert abs(total - ROBOT_72.line_duration_ms) < 0.1
|
||||||
|
|
||||||
|
def test_all_modes_have_positive_dimensions(self):
|
||||||
|
"""All modes should have positive width and height."""
|
||||||
|
for _vis_code, mode in ALL_MODES.items():
|
||||||
|
assert mode.width > 0, f"{mode.name} has invalid width"
|
||||||
|
assert mode.height > 0, f"{mode.name} has invalid height"
|
||||||
|
assert mode.line_duration_ms > 0, f"{mode.name} has invalid line duration"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image decoder tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestImageDecoder:
|
||||||
|
"""Tests for the SSTV image decoder."""
|
||||||
|
|
||||||
|
def test_creates_decoder(self):
|
||||||
|
"""Should create an image decoder for any supported mode."""
|
||||||
|
from utils.sstv.image_decoder import SSTVImageDecoder
|
||||||
|
decoder = SSTVImageDecoder(ROBOT_36)
|
||||||
|
assert decoder.is_complete is False
|
||||||
|
assert decoder.current_line == 0
|
||||||
|
assert decoder.total_lines == 240
|
||||||
|
|
||||||
|
def test_pd120_dual_luminance_lines(self):
|
||||||
|
"""PD120 decoder should expect half the image height in audio lines."""
|
||||||
|
from utils.sstv.image_decoder import SSTVImageDecoder
|
||||||
|
decoder = SSTVImageDecoder(PD_120)
|
||||||
|
assert decoder.total_lines == 248 # 496 / 2
|
||||||
|
|
||||||
|
def test_progress_percent(self):
|
||||||
|
"""Progress should start at 0."""
|
||||||
|
from utils.sstv.image_decoder import SSTVImageDecoder
|
||||||
|
decoder = SSTVImageDecoder(ROBOT_36)
|
||||||
|
assert decoder.progress_percent == 0
|
||||||
|
|
||||||
|
def test_synthetic_robot36_decode(self):
|
||||||
|
"""Should decode a synthetic Robot36 image (all white)."""
|
||||||
|
pytest.importorskip('PIL')
|
||||||
|
from utils.sstv.image_decoder import SSTVImageDecoder
|
||||||
|
|
||||||
|
decoder = SSTVImageDecoder(ROBOT_36)
|
||||||
|
|
||||||
|
# Generate synthetic scanlines (all white = 2300 Hz)
|
||||||
|
# Each line: sync(9ms) + porch(3ms) + Y(88ms) + separator(6ms) + Cr/Cb(44ms)
|
||||||
|
for _line in range(240):
|
||||||
|
parts = []
|
||||||
|
# Sync pulse
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.009))
|
||||||
|
# Porch
|
||||||
|
parts.append(generate_tone(FREQ_BLACK, 0.003))
|
||||||
|
# Y channel (white = 2300 Hz)
|
||||||
|
parts.append(generate_tone(FREQ_WHITE, 0.088))
|
||||||
|
# Separator + porch (6ms)
|
||||||
|
parts.append(generate_tone(FREQ_BLACK, 0.006))
|
||||||
|
# Chroma channel (mid value = 1900 Hz ~ 128)
|
||||||
|
parts.append(generate_tone(1900.0, 0.044))
|
||||||
|
# Pad to line duration
|
||||||
|
line_audio = np.concatenate(parts)
|
||||||
|
line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0)
|
||||||
|
if len(line_audio) < line_samples:
|
||||||
|
line_audio = np.concatenate([
|
||||||
|
line_audio,
|
||||||
|
np.zeros(line_samples - len(line_audio))
|
||||||
|
])
|
||||||
|
|
||||||
|
decoder.feed(line_audio)
|
||||||
|
|
||||||
|
assert decoder.is_complete
|
||||||
|
img = decoder.get_image()
|
||||||
|
assert img is not None
|
||||||
|
assert img.size == (320, 240)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSTVDecoder orchestrator tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSSTVDecoder:
|
||||||
|
"""Tests for the SSTVDecoder orchestrator."""
|
||||||
|
|
||||||
|
def test_decoder_available(self):
|
||||||
|
"""Python decoder should always be available."""
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
assert decoder.decoder_available == 'python-sstv'
|
||||||
|
|
||||||
|
def test_is_sstv_available(self):
|
||||||
|
"""is_sstv_available() should always return True."""
|
||||||
|
assert is_sstv_available() is True
|
||||||
|
|
||||||
|
def test_not_running_initially(self):
|
||||||
|
"""Decoder should not be running on creation."""
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
assert decoder.is_running is False
|
||||||
|
|
||||||
|
def test_doppler_disabled_by_default(self):
|
||||||
|
"""Doppler should be disabled by default."""
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
assert decoder.doppler_enabled is False
|
||||||
|
assert decoder.last_doppler_info is None
|
||||||
|
|
||||||
|
def test_stop_when_not_running(self):
|
||||||
|
"""Stop should be safe to call when not running."""
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
decoder.stop() # Should not raise
|
||||||
|
|
||||||
|
def test_set_callback(self):
|
||||||
|
"""Should accept a callback function."""
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
cb = MagicMock()
|
||||||
|
decoder.set_callback(cb)
|
||||||
|
# Trigger a progress emit
|
||||||
|
decoder._emit_progress(DecodeProgress(status='detecting'))
|
||||||
|
cb.assert_called_once()
|
||||||
|
|
||||||
|
def test_get_images_empty(self):
|
||||||
|
"""Should return empty list initially."""
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
images = decoder.get_images()
|
||||||
|
assert images == []
|
||||||
|
|
||||||
|
def test_decode_file_not_found(self):
|
||||||
|
"""Should raise FileNotFoundError for missing file."""
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
decoder.decode_file('/nonexistent/audio.wav')
|
||||||
|
|
||||||
|
def test_decode_file_with_synthetic_wav(self):
|
||||||
|
"""Should process a WAV file through the decode pipeline."""
|
||||||
|
pytest.importorskip('PIL')
|
||||||
|
|
||||||
|
output_dir = tempfile.mkdtemp()
|
||||||
|
decoder = SSTVDecoder(output_dir=output_dir)
|
||||||
|
|
||||||
|
# Generate a synthetic WAV with a VIS header + short image data
|
||||||
|
vis_header = generate_vis_header(8) # Robot36
|
||||||
|
|
||||||
|
# Add 240 lines of image data after the header
|
||||||
|
image_lines = []
|
||||||
|
for _line in range(240):
|
||||||
|
parts = []
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.009))
|
||||||
|
parts.append(generate_tone(FREQ_BLACK, 0.003))
|
||||||
|
parts.append(generate_tone(1900.0, 0.088)) # mid-gray Y
|
||||||
|
parts.append(generate_tone(FREQ_BLACK, 0.006)) # separator
|
||||||
|
parts.append(generate_tone(1900.0, 0.044)) # chroma
|
||||||
|
line_audio = np.concatenate(parts)
|
||||||
|
line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0)
|
||||||
|
if len(line_audio) < line_samples:
|
||||||
|
line_audio = np.concatenate([
|
||||||
|
line_audio,
|
||||||
|
np.zeros(line_samples - len(line_audio))
|
||||||
|
])
|
||||||
|
image_lines.append(line_audio)
|
||||||
|
|
||||||
|
audio = np.concatenate([
|
||||||
|
np.zeros(4800), # 100ms silence
|
||||||
|
vis_header,
|
||||||
|
*image_lines,
|
||||||
|
np.zeros(4800),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Write WAV file
|
||||||
|
wav_path = Path(output_dir) / 'test_input.wav'
|
||||||
|
raw_int16 = (audio * 32767).astype(np.int16)
|
||||||
|
with wave.open(str(wav_path), 'wb') as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(SAMPLE_RATE)
|
||||||
|
wf.writeframes(raw_int16.tobytes())
|
||||||
|
|
||||||
|
images = decoder.decode_file(wav_path)
|
||||||
|
assert len(images) >= 1
|
||||||
|
assert images[0].mode == 'Robot36'
|
||||||
|
assert Path(images[0].path).exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dataclass tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDataclasses:
|
||||||
|
"""Tests for dataclass serialization."""
|
||||||
|
|
||||||
|
def test_decode_progress_to_dict(self):
|
||||||
|
"""DecodeProgress should serialize correctly."""
|
||||||
|
progress = DecodeProgress(
|
||||||
|
status='decoding',
|
||||||
|
mode='Robot36',
|
||||||
|
progress_percent=50,
|
||||||
|
message='Halfway done',
|
||||||
|
)
|
||||||
|
d = progress.to_dict()
|
||||||
|
assert d['type'] == 'sstv_progress'
|
||||||
|
assert d['status'] == 'decoding'
|
||||||
|
assert d['mode'] == 'Robot36'
|
||||||
|
assert d['progress'] == 50
|
||||||
|
assert d['message'] == 'Halfway done'
|
||||||
|
|
||||||
|
def test_decode_progress_minimal(self):
|
||||||
|
"""DecodeProgress with only status should omit optional fields."""
|
||||||
|
progress = DecodeProgress(status='detecting')
|
||||||
|
d = progress.to_dict()
|
||||||
|
assert 'mode' not in d
|
||||||
|
assert 'message' not in d
|
||||||
|
assert 'image' not in d
|
||||||
|
|
||||||
|
def test_sstv_image_to_dict(self):
|
||||||
|
"""SSTVImage should serialize with URL."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
image = SSTVImage(
|
||||||
|
filename='test.png',
|
||||||
|
path=Path('/tmp/test.png'),
|
||||||
|
mode='Robot36',
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
frequency=145.800,
|
||||||
|
size_bytes=1234,
|
||||||
|
)
|
||||||
|
d = image.to_dict()
|
||||||
|
assert d['filename'] == 'test.png'
|
||||||
|
assert d['mode'] == 'Robot36'
|
||||||
|
assert d['url'] == '/sstv/images/test.png'
|
||||||
|
|
||||||
|
def test_doppler_info_to_dict(self):
|
||||||
|
"""DopplerInfo should serialize with rounding."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
info = DopplerInfo(
|
||||||
|
frequency_hz=145800123.456,
|
||||||
|
shift_hz=123.456,
|
||||||
|
range_rate_km_s=-1.23456,
|
||||||
|
elevation=45.678,
|
||||||
|
azimuth=180.123,
|
||||||
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
d = info.to_dict()
|
||||||
|
assert d['shift_hz'] == 123.5
|
||||||
|
assert d['range_rate_km_s'] == -1.235
|
||||||
|
assert d['elevation'] == 45.7
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIntegration:
|
||||||
|
"""Integration tests verifying the package works as a drop-in replacement."""
|
||||||
|
|
||||||
|
def test_import_from_utils_sstv(self):
|
||||||
|
"""Routes should be able to import from utils.sstv."""
|
||||||
|
from utils.sstv import (
|
||||||
|
ISS_SSTV_FREQ,
|
||||||
|
is_sstv_available,
|
||||||
|
)
|
||||||
|
assert ISS_SSTV_FREQ == 145.800
|
||||||
|
assert is_sstv_available() is True
|
||||||
|
|
||||||
|
def test_sstv_modes_constant(self):
|
||||||
|
"""SSTV_MODES list should be importable."""
|
||||||
|
from utils.sstv import SSTV_MODES
|
||||||
|
assert 'Robot36' in SSTV_MODES
|
||||||
|
assert 'Martin1' in SSTV_MODES
|
||||||
|
assert 'PD120' in SSTV_MODES
|
||||||
|
|
||||||
|
def test_decoder_singleton(self):
|
||||||
|
"""get_sstv_decoder should return a valid decoder."""
|
||||||
|
# Reset the global singleton for test isolation
|
||||||
|
import utils.sstv.sstv_decoder as mod
|
||||||
|
old = mod._decoder
|
||||||
|
mod._decoder = None
|
||||||
|
try:
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
assert decoder is not None
|
||||||
|
assert decoder.decoder_available == 'python-sstv'
|
||||||
|
finally:
|
||||||
|
mod._decoder = old
|
||||||
|
|
||||||
|
@patch('subprocess.Popen')
|
||||||
|
def test_start_creates_subprocess(self, mock_popen):
|
||||||
|
"""start() should create an rtl_fm subprocess."""
|
||||||
|
mock_process = MagicMock()
|
||||||
|
mock_process.stdout = MagicMock()
|
||||||
|
mock_process.stdout.read = MagicMock(return_value=b'')
|
||||||
|
mock_process.stderr = MagicMock()
|
||||||
|
mock_popen.return_value = mock_process
|
||||||
|
|
||||||
|
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
|
||||||
|
success = decoder.start(frequency=145.800, device_index=0)
|
||||||
|
assert success is True
|
||||||
|
assert decoder.is_running is True
|
||||||
|
|
||||||
|
# Verify rtl_fm was called
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
cmd = mock_popen.call_args[0][0]
|
||||||
|
assert cmd[0] == 'rtl_fm'
|
||||||
|
assert '-f' in cmd
|
||||||
|
assert '-M' in cmd
|
||||||
|
|
||||||
|
decoder.stop()
|
||||||
|
assert decoder.is_running is False
|
||||||
@@ -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,168 @@
|
|||||||
|
"""Tests for the waterfall FFT pipeline."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.waterfall_fft import (
|
||||||
|
build_binary_frame,
|
||||||
|
compute_power_spectrum,
|
||||||
|
cu8_to_complex,
|
||||||
|
quantize_to_uint8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCu8ToComplex:
|
||||||
|
"""Tests for cu8_to_complex conversion."""
|
||||||
|
|
||||||
|
def test_zero_maps_to_negative_one(self):
|
||||||
|
# I=0, Q=0 -> approximately -1 - 1j
|
||||||
|
result = cu8_to_complex(bytes([0, 0]))
|
||||||
|
assert result[0].real == pytest.approx(-1.0, abs=0.01)
|
||||||
|
assert result[0].imag == pytest.approx(-1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_255_maps_to_positive_one(self):
|
||||||
|
# I=255, Q=255 -> approximately +1 + 1j
|
||||||
|
result = cu8_to_complex(bytes([255, 255]))
|
||||||
|
assert result[0].real == pytest.approx(1.0, abs=0.01)
|
||||||
|
assert result[0].imag == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_128_maps_to_near_zero(self):
|
||||||
|
# I=128, Q=128 -> approximately 0 + 0j
|
||||||
|
result = cu8_to_complex(bytes([128, 128]))
|
||||||
|
assert abs(result[0].real) < 0.01
|
||||||
|
assert abs(result[0].imag) < 0.01
|
||||||
|
|
||||||
|
def test_output_length(self):
|
||||||
|
raw = bytes(range(256)) * 4 # 1024 bytes -> 512 complex samples
|
||||||
|
result = cu8_to_complex(raw)
|
||||||
|
assert len(result) == 512
|
||||||
|
|
||||||
|
def test_output_dtype(self):
|
||||||
|
result = cu8_to_complex(bytes([100, 200, 50, 150]))
|
||||||
|
assert result.dtype == np.complex64 or np.issubdtype(result.dtype, np.complexfloating)
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputePowerSpectrum:
|
||||||
|
"""Tests for compute_power_spectrum."""
|
||||||
|
|
||||||
|
def test_output_length_matches_fft_size(self):
|
||||||
|
samples = np.zeros(4096, dtype=np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
||||||
|
assert len(result) == 1024
|
||||||
|
|
||||||
|
def test_output_dtype(self):
|
||||||
|
samples = np.zeros(4096, dtype=np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
||||||
|
assert result.dtype == np.float32
|
||||||
|
|
||||||
|
def test_pure_tone_peak_at_correct_bin(self):
|
||||||
|
fft_size = 1024
|
||||||
|
avg_count = 4
|
||||||
|
n = fft_size * avg_count
|
||||||
|
# Generate a pure tone at bin 256 (1/4 of sample rate)
|
||||||
|
t = np.arange(n, dtype=np.float32)
|
||||||
|
freq_bin = 256
|
||||||
|
tone = np.exp(2j * np.pi * freq_bin / fft_size * t).astype(np.complex64)
|
||||||
|
result = compute_power_spectrum(tone, fft_size=fft_size, avg_count=avg_count)
|
||||||
|
# After fftshift, bin 256 maps to index 256 + 512 = 768
|
||||||
|
peak_idx = np.argmax(result)
|
||||||
|
expected_idx = fft_size // 2 + freq_bin
|
||||||
|
assert peak_idx == expected_idx
|
||||||
|
|
||||||
|
def test_insufficient_samples_returns_default(self):
|
||||||
|
# Not enough samples for even one segment
|
||||||
|
samples = np.zeros(100, dtype=np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
||||||
|
assert len(result) == 1024
|
||||||
|
assert np.all(result == -100.0)
|
||||||
|
|
||||||
|
def test_partial_avg_count(self):
|
||||||
|
# Only enough for 2 of 4 requested averages
|
||||||
|
fft_size = 1024
|
||||||
|
samples = np.random.randn(2048).astype(np.float32).view(np.complex64)
|
||||||
|
result = compute_power_spectrum(samples, fft_size=fft_size, avg_count=4)
|
||||||
|
assert len(result) == fft_size
|
||||||
|
# Should still return valid dB values (not -100 default)
|
||||||
|
assert np.any(result != -100.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuantizeToUint8:
|
||||||
|
"""Tests for quantize_to_uint8."""
|
||||||
|
|
||||||
|
def test_db_min_maps_to_zero(self):
|
||||||
|
power = np.array([-90.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 0
|
||||||
|
|
||||||
|
def test_db_max_maps_to_255(self):
|
||||||
|
power = np.array([-20.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 255
|
||||||
|
|
||||||
|
def test_below_min_clamped_to_zero(self):
|
||||||
|
power = np.array([-120.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 0
|
||||||
|
|
||||||
|
def test_above_max_clamped_to_255(self):
|
||||||
|
power = np.array([0.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert result[0] == 255
|
||||||
|
|
||||||
|
def test_midpoint(self):
|
||||||
|
# Midpoint between -90 and -20 is -55 -> ~127-128
|
||||||
|
power = np.array([-55.0], dtype=np.float32)
|
||||||
|
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
||||||
|
assert 125 <= result[0] <= 130
|
||||||
|
|
||||||
|
def test_output_length(self):
|
||||||
|
power = np.random.randn(1024).astype(np.float32) * 30 - 60
|
||||||
|
result = quantize_to_uint8(power)
|
||||||
|
assert len(result) == 1024
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildBinaryFrame:
|
||||||
|
"""Tests for build_binary_frame."""
|
||||||
|
|
||||||
|
def test_header_values(self):
|
||||||
|
bins = bytes([128] * 1024)
|
||||||
|
frame = build_binary_frame(100.0, 102.0, bins)
|
||||||
|
msg_type = frame[0]
|
||||||
|
start_freq, end_freq = struct.unpack_from('<ff', frame, 1)
|
||||||
|
bin_count = struct.unpack_from('<H', frame, 9)[0]
|
||||||
|
assert msg_type == 0x01
|
||||||
|
assert start_freq == pytest.approx(100.0, abs=0.01)
|
||||||
|
assert end_freq == pytest.approx(102.0, abs=0.01)
|
||||||
|
assert bin_count == 1024
|
||||||
|
|
||||||
|
def test_total_length(self):
|
||||||
|
bin_count = 1024
|
||||||
|
bins = bytes([0] * bin_count)
|
||||||
|
frame = build_binary_frame(88.0, 108.0, bins)
|
||||||
|
assert len(frame) == 11 + bin_count
|
||||||
|
|
||||||
|
def test_bins_in_payload(self):
|
||||||
|
bins = bytes(range(256))
|
||||||
|
frame = build_binary_frame(0.0, 1.0, bins)
|
||||||
|
payload = frame[11:]
|
||||||
|
assert payload == bins
|
||||||
|
|
||||||
|
def test_round_trip(self):
|
||||||
|
start = 433.0
|
||||||
|
end = 435.0
|
||||||
|
bins = bytes([i % 256 for i in range(2048)])
|
||||||
|
frame = build_binary_frame(start, end, bins)
|
||||||
|
|
||||||
|
# Parse it back
|
||||||
|
msg_type = frame[0]
|
||||||
|
parsed_start, parsed_end = struct.unpack_from('<ff', frame, 1)
|
||||||
|
parsed_count = struct.unpack_from('<H', frame, 9)[0]
|
||||||
|
parsed_bins = frame[11:]
|
||||||
|
|
||||||
|
assert msg_type == 0x01
|
||||||
|
assert parsed_start == pytest.approx(start, abs=0.01)
|
||||||
|
assert parsed_end == pytest.approx(end, abs=0.01)
|
||||||
|
assert parsed_count == 2048
|
||||||
|
assert parsed_bins == bins
|
||||||
@@ -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']
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user