Compare commits

..

57 Commits

Author SHA1 Message Date
Smittix d8d08a8b1e feat: Add BT Locate and GPS modes with IRK auto-detection
New modes:
- BT Locate: SAR Bluetooth device location with GPS-tagged signal trail,
  RSSI-based proximity bands, audio alerts, and IRK auto-extraction from
  paired devices (macOS plist / Linux BlueZ)
- GPS: Real-time position tracking with live map, speed, heading, altitude,
  satellite info, and track recording via gpsd

Bug fixes:
- Fix ABBA deadlock between session lock and aggregator lock in BT Locate
- Fix bleak scan lifecycle tracking in BluetoothScanner (is_scanning property
  now cross-checks backend state)
- Fix map tile persistence when switching modes
- Use 15s max_age window for fresh detections in BT Locate poll loop

Documentation:
- Update README, FEATURES.md, USAGE.md, and GitHub Pages with new modes

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:24:51 +00:00
Smittix a1cb6b2692 feat: Add SatDump to setup.sh for local (non-Docker) installs
Weather satellite decoding (NOAA APT & Meteor LRPT) was added in the
Dockerfile but setup.sh had no SatDump support, leaving local installs
with a broken weather satellite mode. Adds build-from-source functions
for both Debian and macOS, a check_optional entry, and prompted install
steps in both platform installers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:15:53 +00:00
Smittix 8376415074 feat: Add weather satellite decoder (NOAA APT & Meteor LRPT)
feat: Add weather satellite decoder (NOAA APT & Meteor LRPT)   -  Alpha
2026-02-10 08:36:34 +00:00
Mitch Ross b25615317b Merge upstream/main: sync fork with latest DMR fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:40:25 -05:00
Mitch Ross 311d268b10 Explicitly remove libgtk-3-dev in Dockerfile cleanup step
Adds libgtk-3-dev to the apt-get remove list so it doesn't remain
in the final image. Runtime GTK libs stay for slowrx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:09:50 -05:00
Mitch Ross 6581620cb0 Merge pull request #2 from mitchross/copilot/add-test-coverage-weather-satellite
[WIP] Add test coverage for weather satellite decoder modules
2026-02-09 16:58:09 -05:00
copilot-swe-agent[bot] aa963519e9 Fix str(e) in error responses, remove location modal, document GTK dependency
Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 21:55:30 +00:00
copilot-swe-agent[bot] 4a6dddbb48 Add comprehensive test coverage for weather satellite modules
- Created test_weather_sat_routes.py with 42 tests for all endpoints
- Created test_weather_sat_decoder.py with 47 tests for WeatherSatDecoder class
- Created test_weather_sat_predict.py with 14 tests for pass prediction
- Created test_weather_sat_scheduler.py with 31 tests for auto-scheduler
- Total: 134 test functions across 14 test classes
- All tests follow existing patterns (mocking, fixtures, docstrings)
- Tests cover happy paths, error handling, and edge cases
- Mock all external subprocess calls and HTTP requests

Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 21:50:22 +00:00
copilot-swe-agent[bot] f217230ef4 Initial plan 2026-02-09 21:41:46 +00:00
Mitch Ross e27b4d78cb Merge pull request #1 from mitchross/copilot/fix-security-issues
Address code review feedback for weather satellite decoder
2026-02-09 16:09:39 -05:00
copilot-swe-agent[bot] d41ba61aee Fix security issues, breaking changes, and code cleanup for weather satellite PR
Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 20:58:26 +00:00
copilot-swe-agent[bot] 35cf01c11e Initial plan 2026-02-09 20:52:52 +00:00
Smittix 00c9a6fdd9 Fix DMR audio/text deadlock: start ffmpeg per-client, not at launch
Starting ffmpeg at decoder launch caused a pipe-buffer deadlock: ffmpeg
stdout filled up (~64KB on Linux) before the browser connected to the
audio stream, back-pressuring the entire pipeline and freezing dsd-fme
stderr output (no text data, no syncs, no calls).

New architecture: a mux thread always drains dsd-fme stdout to keep the
pipeline flowing. ffmpeg starts lazily per-client when /dmr/audio/stream
is requested (matching the listening post pattern). The mux forwards
decoded audio to the active ffmpeg with silence fill during voice gaps,
and discards audio when no client is connected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:25:07 +00:00
Smittix fce66a6a60 Fix DMR audio stream failing with "no supported source found"
Digital voice is intermittent — dsd-fme only outputs PCM during active
voice transmissions. Without input, ffmpeg never wrote the WAV header
and the browser got an empty response. Add an audio bridge thread that
feeds 100ms silence chunks during voice gaps so ffmpeg always has input
and the browser receives a continuous WAV stream. Add auto-reconnect
on the frontend if the audio stream drops while the decoder is running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:14:33 +00:00
Smittix b023e4cdc7 Add DMR audio output, frequency persistence, and bookmarks
Stream decoded digital voice audio to the browser via ffmpeg pipeline
(dsd-fme 8kHz PCM → ffmpeg → 44.1kHz WAV → chunked HTTP). Persist
frequency/protocol/gain/ppm settings in localStorage so they survive
page navigation. Add bookmark system for saving and recalling frequencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:05:27 +00:00
Smittix a8f2912b90 Fix waterfall-to-listen SDR busy race condition
Wait for server-side WebSocket stop confirmation before closing the
connection, ensuring the IQ process is fully terminated and the USB
device released. Add retry logic with back-off in the audio start
endpoint as defense-in-depth for any remaining timing gaps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:58:42 +00:00
Smittix a2a7ac8fec Fix banner filter eating dsd-fme data lines and add event log capture
The box-drawing character filter was dropping ANY line containing │ or ─,
including dsd-fme data lines that use these as column separators (e.g.
"DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890"). Now only filters lines
that are purely decorative (no alphanumeric content).

Also adds -J /dev/stderr so dsd-fme writes its event log to stderr
where we capture it, and debug logging of raw stderr lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:41:32 +00:00
Smittix 4e168ff502 Fix dsd-fme DMR flag (-fd is D-STAR, not DMR) and audio output
-fd means D-STAR in dsd-fme, not DMR — causing sync detection
(shared C4FM modulation) but no decoded data. DMR Simplex is -fs.
Also fix -o - (invalid in dsd-fme) to -o null for headless servers,
add D-STAR flag mapping, and handle TGT/SRC output format in parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:31:44 +00:00
Mitch Ross 54c849ab60 Fix weather satellite decoder security, architecture, and race conditions
Security: replace path traversal-vulnerable str().startswith() with
is_relative_to(), anchor path checks to app root, strip filesystem
paths from error responses, add decoder-level path validation.

Architecture: use safe_terminate/register_process for subprocess
lifecycle, replace custom SSE generator with sse_stream(), use
centralized validate_* functions, remove unused app.py declarations.

Bugs: add thread-safe singleton locks, protect _images list across
threads, move blocking process.wait() to async daemon thread, fix
timezone handling for tz-aware datetimes, use full path for image
deduplication, guard TLE auto-refresh during tests, validate
scheduler parameters to avoid 500 errors.

Docker: pin SatDump to v1.2.2 and slowrx to ca6d7012, document
INTERCEPT_IMAGE fallback pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:29:45 -05:00
Mitch Ross 94ee22fdd4 Merge upstream/main: sync fork with conflict resolution
Resolve conflicts keeping local GSM tools in kill_all() process list
and weather satellite config settings while merging upstream changes
including GSM spy removal, DMR fixes, USB device probe, APRS crash
fix, and cross-module frequency routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:06:41 -05:00
Mitch Ross ca15e227cd add test harness 2026-02-08 14:45:12 -05:00
Mitch Ross 1924203c19 Merge upstream/main: add gsm_spy blueprint 2026-02-08 13:15:20 -05:00
Mitch Ross fd0953bfb5 up 2026-02-07 17:56:45 -05:00
Mitch Ross 13be4302c3 Update index.html 2026-02-07 16:07:12 -05:00
Mitch Ross 5fd45d3e94 Merge remote-tracking branch 'upstream/main' 2026-02-07 16:03:32 -05:00
Smittix e88b815dc9 Add shared waterfall UI across SDR modes 2026-02-07 16:01:01 -05:00
Mitch Ross 556a4ffcc2 tweaks
1. utils/weather_sat.py — Added delete_all_images() method that globs for *.png, *.jpg, *.jpeg in the output dir, unlinks each, clears _images list, and returns the
  count.
  2. routes/weather_sat.py — Added DELETE /weather-sat/images route that calls decoder.delete_all_images() and returns {'status': 'ok', 'deleted': count}.
  3. static/js/modes/weather-satellite.js:
    - Added currentModalFilename state variable
    - renderGallery() now sorts images by timestamp descending, groups by date using toLocaleDateString(), renders date headers spanning the grid, and adds a delete
  overlay button on each card
    - showImage() accepts a filename param, stores it in currentModalFilename, and creates a modal toolbar with a delete button
    - Added deleteImage(filename) — confirm dialog → DELETE /weather-sat/images/{filename} → filter from array → re-render + close modal
    - Added deleteAllImages() — confirm dialog → DELETE /weather-sat/images → clear array → re-render
    - Exposed deleteImage, deleteAllImages, and _getModalFilename in public API
  4. static/css/modes/weather-satellite.css:
    - Added position: relative to .wxsat-image-card
    - .wxsat-image-actions — absolute top-right overlay, hidden by default, appears on card hover
    - .wxsat-image-actions button — dark background, turns red on hover
    - .wxsat-date-header — full-grid-width date separator with dimmed uppercase text
    - .wxsat-modal-toolbar — absolute top-left in modal for the delete button
    - .wxsat-modal-btn.delete — turns red on hover
    - .wxsat-gallery-clear-btn — subtle icon button, pushed right via margin-left: auto, turns red on hover
    - Updated .wxsat-gallery-header from justify-content: space-between to gap: 8px for proper 3-child layout
  5. templates/index.html — Added clear-all trash button with SVG icon in the gallery header, wired to WeatherSat.deleteAllImages().
2026-02-07 15:52:52 -05:00
Mitch Ross 03c5d33eb7 Fix race condition: set _running before starting reader thread
The reader thread loop checks self._running but it was being set to
True after _start_satdump() returned, which is after the thread
already started. The thread would see _running=False and exit
immediately without reading any SatDump output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:28:07 -05:00
Mitch Ross f9786aa75a Use PTY for SatDump output capture instead of pipe
SatDump writes to stderr via fwrite() with its custom logger. When
stderr is redirected to a pipe, C runtime fully buffers it. Neither
stdbuf nor bufsize settings help since SatDump doesn't use stdio for
output.

PTY (pseudo-terminal) makes SatDump think it's writing to a real
terminal, which disables buffering. Also strips ANSI escape codes
from the output and properly handles \r progress lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:17:03 -05:00
Mitch Ross b87623cf66 Update weather_sat.py 2026-02-07 15:06:58 -05:00
Mitch Ross 4d24e648ab Update weather_sat.py 2026-02-07 15:04:53 -05:00
Mitch Ross 99f42f66b2 Merge upstream main: add DMR, WebSDR, HF SSTV, alerts, recordings, waterfall
Merges upstream changes into fork while preserving weather satellite
(NOAA APT/Meteor LRPT via SatDump), rtlamr, multi-arch build, and
decoder console features from our branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:29:09 -05:00
Mitch Ross 4bf35cf786 up 2026-02-07 00:30:41 -05:00
Mitch Ross 4ed7969e90 fixes 2026-02-06 15:05:04 -05:00
Mitch Ross 1683d98b90 up 2026-02-06 13:29:45 -05:00
Mitch Ross ff36687f53 Merge branch 'smittix:main' into claude/docker-dual-sdr-config-6yro9 2026-02-05 19:33:49 -05:00
Mitch Ross b860a4309b Add weather satellite auto-scheduler, polar plot, ground track map, and rtlamr Docker support
- Fix SDR device stuck claimed on capture failure via on_complete callback
- Improve SatDump output parsing to emit all lines (throttled 2s) for real-time feedback
- Extract shared pass prediction into utils/weather_sat_predict.py with trajectory/ground track support
- Add auto-scheduler (utils/weather_sat_scheduler.py) using threading.Timer for unattended captures
- Add scheduler API endpoints (enable/disable/status/passes/skip) with SSE event notifications
- Add countdown timer (D/H/M/S) with imminent/active glow states
- Add 24h timeline bar with colored pass markers and current-time cursor
- Add canvas polar plot showing az/el trajectory arc with cardinal directions
- Add Leaflet ground track map with satellite path and observer marker
- Restructure to 3-column layout (passes | polar+map | gallery) with responsive stacking
- Add auto-schedule toggle in strip bar and sidebar
- Add rtlamr (Go utility meter decoder) to Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 19:32:12 -05:00
Mitch Ross f409222f8a Update Dockerfile 2026-02-05 17:16:49 -05:00
Mitch Ross 1c051933b7 Update Dockerfile 2026-02-05 17:12:33 -05:00
Claude c83a2ef56f Add antenna quick reference guides to all mode sidebar panels
Each SDR mode now includes frequency-specific antenna guidance:
- Pager: VHF/UHF dipole info for 153/929 MHz bands
- 433 MHz Sensors: quarter-wave ground plane for ISM band
- Utility Meters: 912 MHz stock antenna tips and upgrades
- APRS: 2m band dipole and commercial options for 144.39 MHz
- SSTV: V-dipole for ISS reception at 145.800 MHz
- AIS: marine VHF antenna for 162 MHz vessel tracking
- Listening Post: wideband discone recommendation with band table
- Meshtastic: LoRa 915/868 MHz antenna upgrades and placement
- ADS-B: 1090 MHz collinear, commercial options, LNA/placement

Each guide includes antenna type, element lengths, placement tips,
and a quick reference table with key specs for the mode.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 22:10:20 +00:00
Mitch Ross 6d1f8f022e Update CLAUDE.md 2026-02-05 17:07:39 -05:00
Claude 500ddf59fe Add multi-arch build support and detailed antenna guide
Multi-arch Docker builds:
- build-multiarch.sh: Cross-compile amd64+arm64 on x64 and push to
  registry, so RPi5 can docker pull instead of building natively
- docker-compose.yml: Add INTERCEPT_IMAGE env var to support pulling
  pre-built images from a registry instead of local build
- README.md: Docker build section rewritten with multi-arch workflow,
  registry pull instructions, and build script options

Weather satellite antenna guide (sidebar panel):
- V-Dipole: ASCII diagram, 53.4cm element length, 120 degree angle,
  materials, orientation, connection instructions
- Turnstile/Crossed Dipole: phasing coax length (37cm RG-58),
  reflector distance (52cm below), RHCP explanation
- QFH Quadrifilar Helix: design overview, materials, height (46cm),
  hemispherical gain pattern
- Placement & LNA: outdoor requirements, coax loss figures,
  LNA mounting position, Nooelec SAWbird+ recommendation, Bias-T
- Quick reference table: wavelength, quarter-wave, elevation,
  duration, polarization, APT/LRPT bandwidth

Also added Weather Satellites and ISS SSTV to README features list,
SatDump to acknowledgments.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:59:55 +00:00
Claude 5e4be0c279 Enable persistent data volume mount for Docker services
Uncomment and enable the ./data:/app/data volume mount on both the
basic and history service profiles. This persists decoded weather
satellite images, the SQLite database, and other data across
container rebuilds. Critical for Docker-only deployments.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:55:44 +00:00
Claude 7b68c19dc5 Add weather satellite decoder for NOAA APT and Meteor LRPT
New module for receiving and decoding weather satellite images using
SatDump CLI. Supports NOAA-15/18/19 (APT) and Meteor-M2-3 (LRPT)
with live SDR capture, pass prediction, and image gallery.

Backend:
- utils/weather_sat.py: SatDump process manager with image watcher
- routes/weather_sat.py: API endpoints (start/stop/images/passes/stream)
- SSE streaming for real-time capture progress
- Pass prediction using existing skyfield + TLE data
- SDR device registry integration (prevents conflicts)

Frontend:
- Sidebar panel with satellite selector and antenna build guide
  (V-dipole and QFH instructions for 137 MHz reception)
- Stats strip with status, frequency, mode, location inputs
- Split-panel layout: upcoming passes list + decoded image gallery
- Full-size image modal viewer
- SSE-driven progress updates during capture

Infrastructure:
- Dockerfile: Add SatDump build from source (headless CLI mode)
  with runtime deps (libpng, libtiff, libjemalloc, libvolk2, libnng)
- Config: WEATHER_SAT_GAIN, SAMPLE_RATE, MIN_ELEVATION, PREDICTION_HOURS
- Nav: Weather Sat entry in Space group (desktop + mobile)

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:45:33 +00:00
Claude 780ba9c58b Update Docker config for dual-SDR setup and arm64 compatibility
- Add slowrx SSTV decoder build with required deps (libsndfile1,
  libgtk-3-dev, libasound2-dev, libfftw3-dev) for arm64/RPi5 support
- Enable USB device passthrough (/dev/bus/usb) on both service profiles
- Add 'basic' profile to main intercept service for explicit selection
- Fix intercept-history container_name conflict (was duplicating 'intercept')

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 19:46:54 +00:00
95 changed files with 25751 additions and 1140 deletions
+6
View File
@@ -55,6 +55,12 @@ intercept_agent_*.cfg
/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.*
+47 -3
View File
@@ -4,11 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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
### 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
# Initial setup (installs dependencies and configures SDR tools)
./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)
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
- `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
- `rtlamr.py` - Utility meter reading
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
### 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)
- `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
**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 |
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
| 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
- `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
## Testing Notes
+69 -1
View File
@@ -24,6 +24,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
multimon-ng \
# Audio tools for Listening Post
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)
aircrack-ng \
iw \
@@ -61,9 +70,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
libncurses-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 \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
@@ -118,6 +140,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make \
&& cp acarsdec /usr/bin/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)
&& cd /tmp \
&& git clone https://github.com/lwvmobile/mbelib.git \
@@ -140,6 +199,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ldconfig \
&& rm -rf /tmp/dsd-fme \
# Cleanup build tools to reduce image size
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
&& apt-get remove -y \
build-essential \
git \
@@ -147,6 +207,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
libncurses-dev \
libsndfile1-dev \
libgtk-3-dev \
libasound2-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
@@ -169,7 +237,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create data directory for persistence
RUN mkdir -p /app/data
RUN mkdir -p /app/data /app/data/weather_sat
# Expose web interface port
EXPOSE 5050
+196 -17
View File
@@ -1,21 +1,200 @@
MIT License
Copyright (c) 2025 smittix
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an OpenPGP
key and encrypt outgoing communications.
Copyright 2025 smittix
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+58 -10
View File
@@ -28,18 +28,23 @@ Support the developer of this open-source project
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
- **Listening Post** - Frequency scanner with audio monitoring
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
- **ISS SSTV** - Receive slow-scan TV from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
- **Satellite Tracking** - Pass prediction using TLE data
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration
- **Spy Stations** - Number stations and diplomatic HF network database
@@ -60,15 +65,54 @@ cd intercept
sudo -E venv/bin/python intercept.py
```
### Docker (Alternative)
### Docker
```bash
git clone https://github.com/smittix/intercept.git
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)
@@ -184,7 +228,7 @@ This project was developed using AI as a coding partner, combining human directi
## License
MIT License - see [LICENSE](LICENSE)
Apache 2.0 License - see [LICENSE](LICENSE)
## Author
@@ -198,8 +242,11 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[dump1090](https://github.com/flightaware/dump1090) |
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
[acarsdec](https://github.com/TLeconte/acarsdec) |
[direwolf](https://github.com/wb2osz/direwolf) |
[rtl_amr](https://github.com/bemasher/rtlamr) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[SatDump](https://github.com/SatDump/SatDump) |
[Celestrak](https://celestrak.org/) |
[Priyom.org](https://priyom.org/)
@@ -211,3 +258,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
+53 -10
View File
@@ -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 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.process import cleanup_stale_processes
from utils.sdr import SDRFactory
@@ -182,6 +182,10 @@ dmr_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -370,6 +374,8 @@ def index() -> str:
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
default_latitude=DEFAULT_LATITUDE,
default_longitude=DEFAULT_LONGITUDE,
)
@@ -641,8 +647,27 @@ def export_bluetooth() -> Response:
})
@app.route('/health')
def health_check() -> Response:
def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode != 'idle'
except Exception:
return False
def _get_dmr_active() -> bool:
"""Check if Digital Voice decoder has an active process."""
try:
from routes import dmr as dmr_module
proc = dmr_module.dmr_dsd_process
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
except Exception:
return False
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
return jsonify({
@@ -656,11 +681,12 @@ def health_check() -> Response:
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
},
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': _get_dmr_active(),
'subghz': _get_subghz_active(),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
@@ -689,8 +715,9 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
]
for proc in processes_to_kill:
@@ -759,6 +786,13 @@ def kill_all() -> Response:
except Exception:
pass
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
@@ -867,6 +901,15 @@ def main() -> None:
from routes import register_blueprints
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)
def update_tle_background():
try:
+139
View File
@@ -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 "============================================"
+33 -1
View File
@@ -7,10 +7,22 @@ import os
import sys
# Application version
VERSION = "2.15.0"
VERSION = "2.16.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.16.0",
"date": "February 2026",
"highlights": [
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
"Weather satellite auto-scheduler with polar plot and ground track map",
"SatDump support for local (non-Docker) installs via setup.sh",
"Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)",
]
},
{
"version": "2.15.0",
"date": "February 2026",
@@ -211,12 +223,32 @@ ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
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_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
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
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
+28 -10
View File
@@ -1,27 +1,31 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
#
# Basic usage:
# docker compose up -d
# Basic usage (build locally):
# 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):
# docker compose --profile history up -d
services:
intercept:
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
container_name: intercept
ports:
- "5050:5050"
# Privileged mode required for USB SDR device access
# Alternatively, use device mapping (see below)
privileged: true
# USB device mapping (alternative to privileged mode)
# devices:
# - /dev/bus/usb:/dev/bus/usb
# volumes:
# Persist data directory
# - ./data:/app/data
# USB device mapping for all USB devices
devices:
- /dev/bus/usb:/dev/bus/usb
volumes:
# Persist decoded images and database across container rebuilds
- ./data:/app/data
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
@@ -40,6 +44,9 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- 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: host
restart: unless-stopped
@@ -53,15 +60,23 @@ services:
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
# Same image/build fallback pattern as above
image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
container_name: intercept
container_name: intercept-history
profiles:
- history
depends_on:
- adsb_db
ports:
- "5050:5050"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
devices:
- /dev/bus/usb:/dev/bus/usb
volumes:
- ./data:/app/data
environment:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
@@ -76,6 +91,9 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- 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
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
+103
View File
@@ -16,6 +16,14 @@ Complete feature list for all modules.
- **Doorbells, remotes, and IoT devices**
- **Smart meters** and utility monitors
## Sub-GHz Analyzer
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
- **Protocol decoding** - identify and decode common Sub-GHz protocols
- **Signal replay/transmit** capabilities for authorized testing
- **Wideband spectrum analysis** with real-time visualization
- **I/Q capture** - record raw samples for offline analysis
## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
@@ -84,6 +92,55 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **SDR conflict detection** - Prevents device collisions with AIS tracking
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
## ACARS Messaging
- **Real-time ACARS decoding** via acarsdec
- **Aircraft datalink messages** - operational, weather, and position reports
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **Message filtering** - filter by message type, flight, or registration
## Listening Post
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
- **Real-time audio monitoring** with FM and SSB demodulation
- **Cross-module frequency routing** from scanner to decoders
- **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
## Weather Satellites
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
- **Auto-scheduler** with pass prediction and automatic capture
- **Polar plot** - real-time satellite position on azimuth/elevation display
- **Ground track map** - orbit path with past/future trajectory
- **Image gallery** with timestamped decoded imagery
## WebSDR
- **KiwiSDR network integration** for remote HF/shortwave listening
- **WebSocket audio streaming** from remote receivers
- **Receiver discovery** with automatic caching
- **Frequency tuning** with band presets
## HF SSTV
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
- **Predefined frequency lookup** for active SSTV calling frequencies
- **Image gallery** with decoded transmissions
## APRS
- **Amateur packet radio** position reports and telemetry via direwolf
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
- **Real-time position tracking** on interactive map
- **Message and telemetry display** from APRS network
## Utility Meter Reading
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
- **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -131,6 +188,52 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Proximity radar** visualization
- **Device type breakdown** chart
## BT Locate (SAR Bluetooth Device Location)
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
### Core Features
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
- **Distance estimation** - Log-distance path loss model with environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.2) - Typical outdoor environment
- **Indoor** (n=3.0) - Indoor with walls and obstacles
### Map & Trail
- Interactive Leaflet map with GPS trail visualization
- Trail points color-coded by proximity band
- Polyline connecting detection points for path visualization
- Supports user-configured tile providers
### Requirements
- Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates)
## GPS Mode
Real-time GPS position tracking with live map visualization.
### Features
- **Live position tracking** - Real-time latitude, longitude, altitude display
- **Interactive map** - Current position on Leaflet map with track history
- **Speed and heading** - Real-time speed (km/h) and compass heading
- **Satellite info** - Number of satellites in view and fix quality
- **Track recording** - Record GPS tracks with export capability
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
### Requirements
- USB GPS receiver connected via gpsd
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
## TSCM Counter-Surveillance Mode
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
+204 -76
View File
@@ -57,92 +57,120 @@ INTERCEPT automatically detects known trackers:
- Samsung SmartTag
- Chipolo
## Sub-GHz Analyzer
1. **Connect HackRF** - Plug in your HackRF One device
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
3. **Start Capture** - Click "Start Capture" to begin signal analysis
4. **View Spectrum** - Real-time spectrum visualization of the selected band
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
### Supported Protocols
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
## Listening Post
1. **Select Hardware** - Choose your SDR type
2. **Set Frequency Range** - Define start and end frequencies for scanning
3. **Start Scanning** - Click "Start Scan" for wideband sweep
4. **View Signals** - Discovered signals are listed with frequency and SNR
5. **Tune In** - Click a signal to tune the audio demodulator
6. **Listen** - Real-time audio plays in your browser
### Demodulation Modes
- **FM** - Narrowband and wideband FM
- **SSB** - Upper and lower sideband for amateur radio and shortwave
## Aircraft Mode (ADS-B)
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
## Satellite Mode
@@ -163,6 +191,106 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
4. Select satellites to add
## Weather Satellites
1. **Set Location** - Enter observer coordinates or use GPS
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
3. **View Passes** - Upcoming passes shown with polar plot and ground track
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
5. **View Images** - Decoded imagery appears in the gallery
### Auto-Scheduler
Enable the auto-scheduler to automatically capture passes:
- Calculates upcoming NOAA and Meteor passes for your location
- Starts SatDump at the correct time and frequency
- Decoded images are saved with timestamps
## AIS Vessel Tracking
1. **Select Hardware** - Choose your SDR type
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
3. **View Map** - Vessels appear on the interactive maritime map
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
### VHF DSC Channel 70
Digital Selective Calling monitoring runs alongside AIS:
- Distress, Urgency, Safety, and Routine messages
- Distress positions plotted with pulsing alert markers
- Audio alerts for critical messages
## APRS
1. **Select Hardware** - Choose your SDR type
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
4. **View Map** - Station positions appear on the interactive map
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
## Utility Meters
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
## BT Locate (SAR Device Location)
1. **Set Target** - Enter one or more target identifiers:
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
2. **Choose Environment** - Select the RF environment preset:
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
- **Indoor** (n=3.0) - For buildings with walls and obstacles
3. **Start Locate** - Click "Start Locate" to begin tracking
4. **Monitor HUD** - The proximity display shows:
- Proximity band (IMMEDIATE / NEAR / FAR)
- Estimated distance in meters
- Raw RSSI and smoothed RSSI average
- Detection count and GPS-tagged points
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
7. **Review Trail** - Check the map for GPS-tagged detection trail
### Hand-off from Bluetooth Mode
1. Open Bluetooth scanning mode and find the target device
2. Click the "Locate" button on the device card
3. BT Locate opens with the device pre-filled
4. Click "Start Locate" to begin tracking
### Tips
- For devices with address randomization (iPhones, modern Android), use the IRK method
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
- Clear the trail when starting a new search area
## GPS Mode
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
2. **View Map** - Your position appears on the interactive map with a track trail
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
4. **Record Track** - Enable track recording to save your path
### Tips
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
- GPS fix may take 30-60 seconds after cold start
- Accuracy improves with more satellites in view
## Meshtastic
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
2. **Start** - Click "Start" to connect to the mesh network
3. **View Messages** - Real-time message stream from the mesh
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
5. **Send Messages** - Type messages to broadcast on the mesh
## Remote Agents (Distributed SIGINT)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+59 -1
View File
@@ -35,7 +35,7 @@
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">15+</span>
<span class="stat-value">20+</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
@@ -77,6 +77,12 @@
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📻</div>
<h3>Sub-GHz Analyzer</h3>
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📻</div>
<h3>Listening Post</h3>
@@ -101,6 +107,18 @@
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📍</div>
<h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛰️</div>
<h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛡️</div>
<h3>TSCM</h3>
@@ -143,11 +161,47 @@
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌧️</div>
<h3>Weather Satellites</h3>
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h3>ISS SSTV</h3>
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div>
<div class="feature-card">
<div class="feature-icon">✈️</div>
<h3>ACARS</h3>
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📍</div>
<h3>APRS</h3>
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<h3>WebSDR</h3>
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Utility Meters</h3>
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
</div>
</div>
</div>
</section>
@@ -194,6 +248,10 @@
<img src="images/ais.png" alt="AIS Vessel Tracking">
<span class="screenshot-label">AIS Vessel Tracking</span>
</div>
<div class="screenshot-item">
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
<span class="screenshot-label">BT Locate — SAR Tracker</span>
</div>
</div>
</div>
</section>
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Download sample NOAA APT recordings for testing the weather satellite
# test-decode feature. These are FM-demodulated audio WAV files.
#
# Usage:
# ./download-weather-sat-samples.sh
# docker exec intercept /app/download-weather-sat-samples.sh
set -euo pipefail
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
mkdir -p "$SAMPLE_DIR"
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
# Source: https://github.com/martinber/noaa-apt
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
echo " -> noaa_apt_argentina.wav (18 MB) ..."
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
else
echo " -> noaa_apt_argentina.wav (already exists)"
fi
echo ""
echo "Done. Test decode with:"
echo " Satellite: NOAA-18"
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
echo " Sample rate: 11025 Hz"
+199 -199
View File
@@ -838,15 +838,15 @@ class ModeManager:
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
elif mode == 'aprs':
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'listening_post':
data['data'] = {
'activity': getattr(self, 'listening_post_activity', []),
@@ -1105,24 +1105,24 @@ class ModeManager:
self.wifi_clients.clear()
elif mode == 'bluetooth':
self.bluetooth_devices.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'dsc':
# Clear DSC data
if hasattr(self, 'dsc_messages'):
@@ -1542,10 +1542,10 @@ class ModeManager:
def _start_wifi(self, params: dict) -> dict:
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
interface = params.get('interface')
channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
# Handle quick scan - returns results synchronously
if scan_type == 'quick':
@@ -1574,21 +1574,21 @@ class ModeManager:
else:
scan_band = 'all'
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
# Start thread to sync data to agent's dictionaries
thread = threading.Thread(
target=self._wifi_data_sync,
@@ -1607,12 +1607,12 @@ class ModeManager:
else:
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
def _wifi_data_sync(self, scanner):
"""Sync WiFi scanner data to agent's data structures."""
@@ -1646,14 +1646,14 @@ class ModeManager:
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
self._wifi_scanner_instance.stop_deep_scan()
def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
if not interface:
return {'status': 'error', 'message': 'WiFi interface required'}
@@ -1680,23 +1680,23 @@ class ModeManager:
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
if gps_manager.is_running:
cmd.append('--gpsd')
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
cmd.append(interface)
try:
@@ -2022,7 +2022,7 @@ class ModeManager:
'agent_gps': gps_manager.position
}
scanner.set_on_device_updated(on_device_updated)
scanner.add_device_callback(on_device_updated)
# Start scanning
if scanner.start_scan(mode=mode_param, duration_s=duration):
@@ -3148,21 +3148,21 @@ class ModeManager:
self.tscm_baseline = {}
if not hasattr(self, 'tscm_anomalies'):
self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
# Get params for what to scan
scan_wifi = params.get('wifi', True)
scan_bt = params.get('bluetooth', True)
scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
# Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id')
@@ -3170,11 +3170,11 @@ class ModeManager:
started_scans = []
# Start the combined TSCM scanner thread using existing Intercept functions
thread = threading.Thread(
target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True
)
thread = threading.Thread(
target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True
)
thread.start()
self.output_threads['tscm'] = thread
@@ -3193,9 +3193,9 @@ class ModeManager:
'scanning': started_scans
}
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None, sweep_type: str | None = None):
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None, sweep_type: str | None = None):
"""Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly:
@@ -3208,20 +3208,20 @@ class ModeManager:
stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
sweep_ranges = None
# Load baseline if specified (same as local mode)
baseline = None
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
sweep_ranges = None
# Load baseline if specified (same as local mode)
baseline = None
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
baseline = get_tscm_baseline(baseline_id)
if baseline:
@@ -3242,9 +3242,9 @@ class ModeManager:
self._tscm_correlation = None
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
last_rf_scan = 0
rf_scan_interval = 30
@@ -3290,63 +3290,63 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
# Bluetooth scan using Intercept's function (same as local mode)
if scan_bt:
@@ -3380,18 +3380,18 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.bluetooth_devices[mac] = enriched
except Exception as e:
@@ -3402,11 +3402,11 @@ class ModeManager:
try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals(
sdr_device,
stop_check=agent_stop_check,
sweep_ranges=sweep_ranges
)
rf_signals = _scan_rf_signals(
sdr_device,
stop_check=agent_stop_check,
sweep_ranges=sweep_ranges
)
# Analyze each RF signal like local mode does
analyzed_signals = []
@@ -3426,17 +3426,17 @@ class ModeManager:
analyzed['reasons'] = classification.get('reasons', [])
# Use correlation engine for scoring (same as local mode)
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
analyzed['is_threat'] = is_threat
analyzed_signals.append(analyzed)
+3 -3
View File
@@ -1,10 +1,10 @@
[project]
name = "intercept"
version = "2.15.0"
version = "2.16.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
license = {text = "Apache-2.0"}
authors = [
{name = "Intercept Contributors"}
]
@@ -14,7 +14,7 @@ classifiers = [
"Environment :: Web Environment",
"Framework :: Flask",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Apache Software License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
+3
View File
@@ -32,6 +32,9 @@ scapy>=2.4.5
# QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
cryptography>=41.0.0
# Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0
# pytest-cov>=4.0.0
+6
View File
@@ -26,11 +26,14 @@ def register_blueprints(app):
from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
from .weather_sat import weather_sat_bp
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
from .subghz import subghz_bp
from .bt_locate import bt_locate_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -56,11 +59,14 @@ def register_blueprints(app):
app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
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(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
# Initialize TSCM state with queue and lock from app
import app as app_module
+101 -79
View File
@@ -19,15 +19,16 @@ from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
@@ -72,14 +73,19 @@ def find_multimon_ng() -> Optional[str]:
return shutil.which('multimon-ng')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
# Path to direwolf config file
@@ -1414,19 +1420,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
@aprs_bp.route('/tools')
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
return jsonify({
'rtl_fm': has_rtl_fm,
'direwolf': has_direwolf,
'multimon_ng': has_multimon,
'ready': has_rtl_fm and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
})
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({
'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm,
'direwolf': has_direwolf,
'multimon_ng': has_multimon,
'ready': has_fm_demod and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
})
@aprs_bp.route('/status')
@@ -1467,20 +1476,12 @@ def start_aprs() -> Response:
'message': 'APRS decoder already running'
}), 409
# Check for required tools
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400
@@ -1488,12 +1489,31 @@ def start_aprs() -> Response:
data = request.json or {}
# Validate inputs
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else:
if find_rx_fm() is None:
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs')
@@ -1525,28 +1545,29 @@ def start_aprs() -> Response:
aprs_last_packet_time = None
aprs_stations = {}
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
freq_hz = f"{float(frequency)}M"
rtl_cmd = [
rtl_fm_path,
'-f', freq_hz,
'-M', 'nfm', # Narrowband FM for APRS
'-s', '22050', # Sample rate matching direwolf -r 22050
'-E', 'dc', # Enable DC blocking filter for cleaner audio
'-A', 'fast', # Fast AGC for packet bursts
'-d', str(device),
]
# Gain: 0 means auto, otherwise set specific gain
if gain and str(gain) != '0':
rtl_cmd.extend(['-g', str(gain)])
# PPM frequency correction
if ppm and str(ppm) != '0':
rtl_cmd.extend(['-p', str(ppm)])
# Output raw audio to stdout
rtl_cmd.append('-')
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=float(frequency),
sample_rate=22050,
gain=float(gain) if gain and str(gain) != '0' else None,
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
# APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command
if direwolf_path:
@@ -1669,13 +1690,14 @@ def start_aprs() -> Response:
)
thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'decoder': decoder_name
})
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'sdr_type': sdr_type.value,
'decoder': decoder_name
})
except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}")
+19 -13
View File
@@ -37,11 +37,17 @@ def find_rtl_fm():
return shutil.which('rtl_fm')
def find_ffmpeg():
return shutil.which('ffmpeg')
def kill_audio_processes():
def find_ffmpeg():
return shutil.which('ffmpeg')
def _rtl_fm_demod_mode(modulation):
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def kill_audio_processes():
"""Kill any running audio processes."""
global audio_process, rtl_process
@@ -104,14 +110,14 @@ def start_audio_stream(config):
freq_hz = int(freq * 1e6)
rtl_cmd = [
rtl_fm,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
rtl_cmd = [
rtl_fm,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
'-l', str(squelch),
]
+177 -177
View File
@@ -7,40 +7,40 @@ aggregation, and heuristics.
from __future__ import annotations
import csv
import io
import json
import logging
import threading
import time
import csv
import io
import json
import logging
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request, session
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
TrackerType,
TrackerConfidence,
get_tracker_engine,
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
logger = logging.getLogger('intercept.bluetooth_v2')
# Blueprint
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
# =============================================================================
# DATABASE FUNCTIONS
@@ -172,20 +172,20 @@ def get_all_baselines() -> list[dict]:
return [dict(row) for row in cursor]
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
# =============================================================================
@@ -206,7 +206,7 @@ def get_capabilities():
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
def start_scan():
def start_scan():
"""
Start Bluetooth scanning.
@@ -236,42 +236,42 @@ def start_scan():
# Get scanner instance
scanner = get_bluetooth_scanner(adapter_id)
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if scanner._on_device_updated is None:
scanner._on_device_updated = _handle_seen_before
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if _handle_seen_before not in scanner._on_device_updated_callbacks:
scanner.add_device_callback(_handle_seen_before)
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists
baseline_id = get_active_baseline_id()
if baseline_id:
device_ids = get_baseline_device_ids(baseline_id)
@@ -896,15 +896,15 @@ def stream_events():
else:
return event_type, event
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
return Response(
event_generator(),
@@ -988,34 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
devices = scanner.get_devices()
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count,
'range_band': device.range_band,
@@ -1229,38 +1229,38 @@ def get_device_timeseries(device_key: str):
return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
return 'wearable'
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
@@ -1269,41 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
return 'computer'
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
return 'peripheral'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
# Check by class of device
if device.major_class:
+284
View File
@@ -0,0 +1,284 @@
"""
BT Locate — Bluetooth SAR Device Location Flask Blueprint.
Provides endpoints for managing locate sessions, streaming detection events,
and retrieving GPS-tagged signal trails.
"""
from __future__ import annotations
import logging
from collections.abc import Generator
from flask import Blueprint, Response, jsonify, request
from utils.bluetooth.irk_extractor import get_paired_irks
from utils.bt_locate import (
Environment,
LocateTarget,
get_locate_session,
resolve_rpa,
start_locate_session,
stop_locate_session,
)
from utils.sse import format_sse
logger = logging.getLogger('intercept.bt_locate')
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
@bt_locate_bp.route('/start', methods=['POST'])
def start_session():
"""
Start a locate session.
Request JSON:
- mac_address: Target MAC address (optional)
- name_pattern: Target name substring (optional)
- irk_hex: Identity Resolving Key hex string (optional)
- device_id: Device ID from Bluetooth scanner (optional)
- known_name: Hand-off device name (optional)
- known_manufacturer: Hand-off manufacturer (optional)
- last_known_rssi: Hand-off last RSSI (optional)
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
Returns:
JSON with session status.
"""
data = request.get_json() or {}
# Build target
target = LocateTarget(
mac_address=data.get('mac_address'),
name_pattern=data.get('name_pattern'),
irk_hex=data.get('irk_hex'),
device_id=data.get('device_id'),
known_name=data.get('known_name'),
known_manufacturer=data.get('known_manufacturer'),
last_known_rssi=data.get('last_known_rssi'),
)
# At least one identifier required
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]):
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
# Parse environment
env_str = data.get('environment', 'OUTDOOR').upper()
try:
environment = Environment[env_str]
except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
custom_exponent = data.get('custom_exponent')
if custom_exponent is not None:
try:
custom_exponent = float(custom_exponent)
except (ValueError, TypeError):
return jsonify({'error': 'custom_exponent must be a number'}), 400
# Fallback coordinates when GPS is unavailable (from user settings)
fallback_lat = None
fallback_lon = None
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
try:
fallback_lat = float(data['fallback_lat'])
fallback_lon = float(data['fallback_lon'])
except (ValueError, TypeError):
pass
logger.info(
f"Starting locate session: target={target.to_dict()}, "
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
)
session = start_locate_session(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
return jsonify({
'status': 'started',
'session': session.get_status(),
})
@bt_locate_bp.route('/stop', methods=['POST'])
def stop_session():
"""Stop the active locate session."""
session = get_locate_session()
if not session:
return jsonify({'status': 'no_session'})
stop_locate_session()
return jsonify({'status': 'stopped'})
@bt_locate_bp.route('/status', methods=['GET'])
def get_status():
"""Get locate session status."""
session = get_locate_session()
if not session:
return jsonify({
'active': False,
'target': None,
})
return jsonify(session.get_status())
@bt_locate_bp.route('/trail', methods=['GET'])
def get_trail():
"""Get detection trail data."""
session = get_locate_session()
if not session:
return jsonify({'trail': [], 'gps_trail': []})
return jsonify({
'trail': session.get_trail(),
'gps_trail': session.get_gps_trail(),
})
@bt_locate_bp.route('/stream', methods=['GET'])
def stream_detections():
"""SSE stream of detection events."""
def event_generator() -> Generator[str, None, None]:
while True:
# Re-fetch session each iteration in case it changes
s = get_locate_session()
if not s:
yield format_sse({'type': 'session_ended'}, event='session_ended')
return
try:
event = s.event_queue.get(timeout=2.0)
yield format_sse(event, event='detection')
except Exception:
yield format_sse({}, event='ping')
return Response(
event_generator(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
}
)
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
def test_resolve_rpa():
"""
Test if an IRK resolves to a given address.
Request JSON:
- irk_hex: 16-byte IRK as hex string
- address: BLE address string
Returns:
JSON with resolution result.
"""
data = request.get_json() or {}
irk_hex = data.get('irk_hex', '')
address = data.get('address', '')
if not irk_hex or not address:
return jsonify({'error': 'irk_hex and address are required'}), 400
try:
irk = bytes.fromhex(irk_hex)
except ValueError:
return jsonify({'error': 'Invalid IRK hex string'}), 400
if len(irk) != 16:
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
result = resolve_rpa(irk, address)
return jsonify({
'resolved': result,
'irk_hex': irk_hex,
'address': address,
})
@bt_locate_bp.route('/environment', methods=['POST'])
def set_environment():
"""Update the environment on the active session."""
session = get_locate_session()
if not session:
return jsonify({'error': 'no active session'}), 400
data = request.get_json() or {}
env_str = data.get('environment', '').upper()
try:
environment = Environment[env_str]
except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
custom_exponent = data.get('custom_exponent')
if custom_exponent is not None:
try:
custom_exponent = float(custom_exponent)
except (ValueError, TypeError):
custom_exponent = None
session.set_environment(environment, custom_exponent)
return jsonify({
'status': 'updated',
'environment': environment.name,
'path_loss_exponent': session.estimator.n,
})
@bt_locate_bp.route('/debug', methods=['GET'])
def debug_matching():
"""Debug endpoint showing scanner devices and match results."""
session = get_locate_session()
if not session:
return jsonify({'error': 'no session'})
scanner = session._scanner
if not scanner:
return jsonify({'error': 'no scanner'})
devices = scanner.get_devices(max_age_seconds=30)
return jsonify({
'target': session.target.to_dict(),
'device_count': len(devices),
'devices': [
{
'device_id': d.device_id,
'address': d.address,
'name': d.name,
'rssi': d.rssi_current,
'matches': session.target.matches(d),
}
for d in devices
],
})
@bt_locate_bp.route('/paired_irks', methods=['GET'])
def paired_irks():
"""Return paired Bluetooth devices that have IRKs."""
try:
devices = get_paired_irks()
except Exception as e:
logger.exception("Failed to read paired IRKs")
return jsonify({'devices': [], 'error': str(e)})
return jsonify({'devices': devices})
@bt_locate_bp.route('/clear_trail', methods=['POST'])
def clear_trail():
"""Clear the detection trail."""
session = get_locate_session()
if not session:
return jsonify({'status': 'no_session'})
session.clear_trail()
return jsonify({'status': 'cleared'})
+308 -99
View File
@@ -21,6 +21,7 @@ from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -39,10 +40,18 @@ dmr_rtl_process: Optional[subprocess.Popen] = None
dmr_dsd_process: Optional[subprocess.Popen] = None
dmr_thread: Optional[threading.Thread] = None
dmr_running = False
dmr_has_audio = False # True when ffmpeg available and dsd outputs audio
dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None
# Audio mux: the sole reader of dsd-fme stdout. 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']
# Classic dsd flags
@@ -56,14 +65,16 @@ _DSD_PROTOCOL_FLAGS = {
}
# dsd-fme remapped several flags from classic DSD:
# -fp = ProVoice (NOT P25), -fi = NXDN48 (NOT D-Star),
# -f1 = P25 Phase 1, -ft = XDMA multi-protocol decoder
# -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 = {
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
'dmr': ['-fd'], # DMR (classic flag, works in dsd-fme)
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!)
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
'nxdn': ['-fn'], # NXDN96
'dstar': [], # No dedicated flag in dsd-fme; auto-detect
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
}
@@ -71,7 +82,6 @@ _DSD_FME_PROTOCOL_FLAGS = {
# sync reliability vs letting dsd-fme auto-detect modulation type.
_DSD_FME_MODULATION = {
'dmr': ['-mc'], # C4FM
'p25': ['-mc'], # C4FM (Phase 1; Phase 2 would use -mq)
'nxdn': ['-mc'], # C4FM
}
@@ -100,6 +110,16 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event.
@@ -111,8 +131,11 @@ def parse_dsd_output(line: str) -> dict | None:
return None
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# These contain box-drawing characters or are pure decoration.
if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line):
# 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
@@ -132,8 +155,9 @@ def parse_dsd_output(line: str) -> dict | None:
# 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'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
)
if tg_match:
result = {
@@ -192,6 +216,97 @@ def parse_dsd_output(line: str) -> dict | None:
_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."""
@@ -239,6 +354,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
if not text:
continue
logger.debug("DSD raw: %s", text)
parsed = parse_dsd_output(text)
if parsed:
_queue_put(parsed)
@@ -258,7 +374,11 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
logger.error(f"DSD stream error: {e}")
finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
global dmr_has_audio
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
# Capture exit info for diagnostics
rc = dsd_process.poll()
reason = 'stopped'
@@ -272,19 +392,9 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
except Exception:
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup both processes
for proc in [dsd_process, rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
# 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})
@@ -304,10 +414,14 @@ def check_tools() -> Response:
"""Check for required tools."""
dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
return jsonify({
'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None,
'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,
})
@@ -315,20 +429,13 @@ def check_tools() -> Response:
@dmr_bp.route('/start', methods=['POST'])
def start_dmr() -> Response:
"""Start digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
global dmr_rtl_process, dmr_dsd_process, dmr_thread
global dmr_running, dmr_has_audio, dmr_active_device
dsd_path, is_fme = find_dsd()
if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
data = request.json or {}
try:
@@ -340,9 +447,25 @@ def start_dmr() -> Response:
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
if sdr_type == SDRType.RTL_SDR:
if not find_rtl_fm():
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
else:
if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 503
# Clear stale queue
try:
while True:
@@ -350,40 +473,63 @@ def start_dmr() -> Response:
except queue.Empty:
pass
# Reserve running state before we start claiming resources/processes
# so concurrent /start requests cannot race each other.
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dmr_running = True
dmr_has_audio = False
# Claim SDR device — use protocol name so the device panel shows
# "D-STAR", "P25", etc. instead of always "DMR"
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
error = app_module.claim_sdr_device(device, mode_label)
if error:
with dmr_lock:
dmr_running = False
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device
freq_hz = int(frequency * 1e6)
# Build rtl_fm command (48kHz sample rate for DSD).
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
# mid-frame, destroying DSD sync. The decoder handles silence
# internally via its own frame-sync detection.
rtl_cmd = [
rtl_fm_path,
'-M', 'fm',
'-f', str(freq_hz),
'-s', '48000',
'-g', str(gain),
'-d', str(device),
'-l', '0',
]
if ppm != 0:
rtl_cmd.extend(['-p', str(ppm)])
# Build FM demodulation command via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=frequency,
sample_rate=48000,
gain=float(gain) if gain > 0 else None,
ppm=int(ppm) if ppm != 0 else None,
modulation='fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR:
# Keep squelch fully open for digital bitstreams.
rtl_cmd.extend(['-l', '0'])
except Exception as e:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build DSD command
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
# instead of PulseAudio which may not be available under sudo
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-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:
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):
@@ -399,10 +545,13 @@ def start_dmr() -> Response:
)
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(
dsd_cmd,
stdin=dmr_rtl_process.stdout,
stdout=subprocess.DEVNULL,
stdout=dsd_stdout,
stderr=subprocess.PIPE,
)
register_process(dmr_dsd_process)
@@ -410,6 +559,17 @@ def start_dmr() -> Response:
# Allow rtl_fm to send directly to dsd
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)
rtl_rc = dmr_rtl_process.poll()
@@ -423,24 +583,8 @@ def start_dmr() -> Response:
if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
# Terminate surviving process and unregister both
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
# Terminate surviving processes and release resources.
_reset_runtime_state(release_device=True)
# Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip()
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
@@ -461,7 +605,6 @@ def start_dmr() -> Response:
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
dmr_running = True
dmr_thread = threading.Thread(
target=stream_dsd_output,
args=(dmr_rtl_process, dmr_dsd_process),
@@ -473,43 +616,21 @@ def start_dmr() -> Response:
'status': 'started',
'frequency': frequency,
'protocol': protocol,
'sdr_type': sdr_type.value,
'has_audio': dmr_has_audio,
})
except Exception as e:
logger.error(f"Failed to start DMR: {e}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response:
"""Stop digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device
with dmr_lock:
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
if proc:
unregister_process(proc)
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
_reset_runtime_state(release_device=True)
return jsonify({'status': 'stopped'})
@@ -520,9 +641,97 @@ def dmr_status() -> Response:
return jsonify({
'running': dmr_running,
'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')
def stream_dmr() -> Response:
"""SSE stream for DMR decoder events."""
+60 -14
View File
@@ -4,19 +4,20 @@ from __future__ import annotations
import queue
import time
from typing import Generator
from collections.abc import Generator
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify
from utils.logging import get_logger
from utils.sse import format_sse
from utils.gps import (
GPSPosition,
GPSSkyData,
get_current_position,
get_gps_reader,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition,
)
from utils.logging import get_logger
from utils.sse import format_sse
logger = get_logger('intercept.gps')
@@ -29,12 +30,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
def _position_callback(position: GPSPosition) -> None:
"""Callback to queue position updates for SSE stream."""
try:
_gps_queue.put_nowait(position.to_dict())
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Full:
# Discard oldest if queue is full
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait(position.to_dict())
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
except queue.Empty:
pass
def _sky_callback(sky: GPSSkyData) -> None:
"""Callback to queue sky data updates for SSE stream."""
try:
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Full:
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
except queue.Empty:
pass
@@ -53,11 +66,13 @@ def auto_connect_gps():
reader = get_gps_reader()
if reader and reader.is_running:
position = reader.position
sky = reader.sky
return jsonify({
'status': 'connected',
'source': 'gpsd',
'has_fix': position is not None,
'position': position.to_dict() if position else None
'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
})
# Try to connect to gpsd on localhost:2947
@@ -84,14 +99,17 @@ def auto_connect_gps():
break
# Start the gpsd client
success = start_gpsd(host, port, callback=_position_callback)
success = start_gpsd(host, port,
callback=_position_callback,
sky_callback=_sky_callback)
if success:
return jsonify({
'status': 'connected',
'source': 'gpsd',
'has_fix': False,
'position': None
'position': None,
'sky': None,
})
else:
return jsonify({
@@ -106,6 +124,7 @@ def stop_gps_reader():
reader = get_gps_reader()
if reader:
reader.remove_callback(_position_callback)
reader.remove_sky_callback(_sky_callback)
stop_gps()
@@ -122,15 +141,18 @@ def get_gps_status():
'running': False,
'device': None,
'position': None,
'sky': None,
'error': None,
'message': 'GPS client not started'
})
position = reader.position
sky = reader.sky
return jsonify({
'running': reader.is_running,
'device': reader.device_path,
'position': position.to_dict() if position else None,
'sky': sky.to_dict() if sky else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,
'error': reader.error,
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
@@ -161,18 +183,42 @@ def get_position():
})
@gps_bp.route('/satellites')
def get_satellites():
"""Get current satellite sky view data."""
reader = get_gps_reader()
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS client not running'
}), 400
sky = reader.sky
if sky:
return jsonify({
'status': 'ok',
'sky': sky.to_dict()
})
else:
return jsonify({
'status': 'waiting',
'message': 'Waiting for satellite data'
})
@gps_bp.route('/stream')
def stream_gps():
"""SSE stream of GPS position updates."""
"""SSE stream of GPS position and sky updates."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
position = _gps_queue.get(timeout=1)
data = _gps_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse({'type': 'position', **position})
yield format_sse(data)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
+76 -38
View File
@@ -102,15 +102,21 @@ def find_ffmpeg() -> str | None:
return shutil.which('ffmpeg')
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
@@ -207,14 +213,14 @@ def scanner_loop():
resample_rate = 24000
# Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [
rtl_fm_path,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
rtl_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
]
# Add bias-t flag if enabled (for external LNA power)
if scanner_config.get('bias_t', False):
@@ -679,14 +685,14 @@ def _start_audio_stream(frequency: float, modulation: str):
return
freq_hz = int(frequency * 1e6)
sdr_cmd = [
rtl_fm_path,
'-M', modulation,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
sdr_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(modulation),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']),
]
if scanner_config.get('bias_t', False):
@@ -1310,20 +1316,35 @@ def start_audio() -> Response:
_stop_waterfall_internal()
time.sleep(0.2)
# Release waterfall device claim if the WebSocket waterfall is still
# holding it. The JS client sends a stop command and closes the
# WebSocket before requesting audio, but the backend handler may not
# have finished its cleanup yet.
device_status = app_module.get_sdr_device_status()
if device_status.get(device) == 'waterfall':
app_module.release_sdr_device(device)
time.sleep(0.3)
# Claim device for listening audio
# 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 not None:
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:
return jsonify({
'status': 'error',
@@ -1441,13 +1462,30 @@ def stream_audio() -> Response:
if not proc or not proc.stdout:
return
try:
# First byte timeout to avoid hanging clients forever
# Drain stale audio that accumulated in the pipe buffer
# between pipeline start and stream connection. Keep the
# first chunk (contains WAV header) and discard the rest
# so the browser starts close to real-time.
header_chunk = None
while True:
ready, _, _ = select.select([proc.stdout], [], [], 0)
if not ready:
break
chunk = proc.stdout.read(8192)
if not chunk:
break
if header_chunk is None:
header_chunk = chunk
if header_chunk:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 3.0
while audio_running and proc.poll() is None:
# Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = proc.stdout.read(4096)
chunk = proc.stdout.read(8192)
if chunk:
yield chunk
else:
+121 -15
View File
@@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES
from utils.database import (
get_tracked_satellites,
add_tracked_satellite,
bulk_add_tracked_satellites,
update_tracked_satellite,
remove_tracked_satellite,
)
from utils.logging import satellite_logger as logger
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
@@ -31,6 +38,43 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES)
def _load_db_satellites_into_cache():
"""Load user-tracked satellites from DB into the TLE cache."""
global _tle_cache
try:
db_sats = get_tracked_satellites()
loaded = 0
for sat in db_sats:
if sat['tle_line1'] and sat['tle_line2']:
# Use a cache key derived from name (sanitised)
cache_key = sat['name'].replace(' ', '-').upper()
if cache_key not in _tle_cache:
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
loaded += 1
if loaded:
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
except Exception as e:
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
def init_tle_auto_refresh():
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading
def _auto_refresh_tle():
try:
_load_db_satellites_into_cache()
updated = refresh_tle_data()
if updated:
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
except Exception as e:
logger.warning(f"Auto TLE refresh failed: {e}")
# Start auto-refresh in background
threading.Timer(2.0, _auto_refresh_tle).start()
logger.info("TLE auto-refresh scheduled")
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
"""
Fetch real-time ISS position from external APIs.
@@ -153,15 +197,11 @@ def predict_passes():
norad_to_name = {
25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
@@ -172,10 +212,6 @@ def predict_passes():
passes = []
colors = {
'ISS': '#00ffff',
'NOAA-15': '#00ff00',
'NOAA-18': '#ff6600',
'NOAA-19': '#ff3366',
'NOAA-20': '#00ffaa',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff'
}
@@ -312,10 +348,6 @@ def get_satellite_position():
norad_to_name = {
25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
@@ -481,7 +513,8 @@ def update_tle():
'updated': updated
})
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>')
@@ -535,4 +568,77 @@ def fetch_celestrak(category):
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
# =============================================================================
# Tracked Satellites CRUD
# =============================================================================
@satellite_bp.route('/tracked', methods=['GET'])
def list_tracked_satellites():
"""Return all tracked satellites from the database."""
enabled_only = request.args.get('enabled', '').lower() == 'true'
sats = get_tracked_satellites(enabled_only=enabled_only)
return jsonify({'status': 'success', 'satellites': sats})
@satellite_bp.route('/tracked', methods=['POST'])
def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites."""
global _tle_cache
data = request.json
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data]
added = 0
for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '')
if not norad_id or not name:
continue
tle1 = sat.get('tle_line1', sat.get('tle1'))
tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True)
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
added += 1
# Also inject into TLE cache if we have TLE data
if tle1 and tle2:
cache_key = name.replace(' ', '-').upper()
_tle_cache[cache_key] = (name, tle1, tle2)
return jsonify({
'status': 'success',
'added': added,
'satellites': get_tracked_satellites(),
})
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
def update_tracked_satellite_endpoint(norad_id):
"""Update the enabled state of a tracked satellite."""
data = request.json or {}
enabled = data.get('enabled')
if enabled is None:
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok:
return jsonify({'status': 'success'})
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
def delete_tracked_satellite_endpoint(norad_id):
"""Remove a tracked satellite by NORAD ID."""
ok, msg = remove_tracked_satellite(str(norad_id))
if ok:
return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404
return jsonify({'status': 'error', 'message': msg}), status_code
+424
View File
@@ -0,0 +1,424 @@
"""SubGHz transceiver routes.
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
signal replay/transmit, and wideband spectrum analysis.
"""
from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import sse_stream
from utils.subghz import get_subghz_manager
from utils.constants import (
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ,
SUBGHZ_LNA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MAX,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_TX_MAX_DURATION,
SUBGHZ_SAMPLE_RATES,
SUBGHZ_PRESETS,
)
logger = get_logger('intercept.subghz')
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
# SSE queue for streaming events to frontend
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue."""
try:
_subghz_queue.put_nowait(event)
except queue.Full:
try:
_subghz_queue.get_nowait()
_subghz_queue.put_nowait(event)
except queue.Empty:
pass
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
raw = data.get(key)
if raw is None:
return None, f'{key} is required'
try:
freq_hz = int(raw)
freq_mhz = freq_hz / 1_000_000
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
return freq_hz, None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_serial(data: dict) -> str | None:
"""Extract and validate optional HackRF device serial."""
serial = data.get('device_serial', '')
if not serial or not isinstance(serial, str):
return None
# HackRF serials are hex strings
serial = serial.strip()
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
return serial
return None
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping."""
try:
val = int(data.get(key, default))
return max(min_val, min(max_val, val))
except (ValueError, TypeError):
return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default)
if not isinstance(profile, str):
return default
profile = profile.strip().lower()
if profile in {'weather', 'all'}:
return profile
return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key)
if raw is None or raw == '':
return None, None
try:
return float(raw), None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default
# ------------------------------------------------------------------
# STATUS
# ------------------------------------------------------------------
@subghz_bp.route('/status')
def get_status():
manager = get_subghz_manager()
return jsonify(manager.get_status())
@subghz_bp.route('/presets')
def get_presets():
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
# ------------------------------------------------------------------
# RECEIVE
# ------------------------------------------------------------------
@subghz_bp.route('/receive/start', methods=['POST'])
def start_receive():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_receive(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/receive/stop', methods=['POST'])
def stop_receive():
manager = get_subghz_manager()
result = manager.stop_receive()
return jsonify(result)
# ------------------------------------------------------------------
# DECODE
# ------------------------------------------------------------------
@subghz_bp.route('/decode/start', methods=['POST'])
def start_decode():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_decode(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
decode_profile=decode_profile,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/decode/stop', methods=['POST'])
def stop_decode():
manager = get_subghz_manager()
result = manager.stop_decode()
return jsonify(result)
# ------------------------------------------------------------------
# TRANSMIT
# ------------------------------------------------------------------
@subghz_bp.route('/transmit', methods=['POST'])
def start_transmit():
data = request.get_json(silent=True) or {}
capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
# Sanitize capture_id
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.transmit(
capture_id=capture_id,
tx_gain=tx_gain,
max_duration=max_duration,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 400
return jsonify(result), status_code
@subghz_bp.route('/transmit/stop', methods=['POST'])
def stop_transmit():
manager = get_subghz_manager()
result = manager.stop_transmit()
return jsonify(result)
# ------------------------------------------------------------------
# SWEEP
# ------------------------------------------------------------------
@subghz_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
data = request.get_json(silent=True) or {}
try:
freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_sweep(
freq_start_mhz=freq_start,
freq_end_mhz=freq_end,
bin_width=bin_width,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
manager = get_subghz_manager()
result = manager.stop_sweep()
return jsonify(result)
# ------------------------------------------------------------------
# CAPTURES LIBRARY
# ------------------------------------------------------------------
@subghz_bp.route('/captures')
def list_captures():
manager = get_subghz_manager()
captures = manager.list_captures()
return jsonify({
'status': 'ok',
'captures': [c.to_dict() for c in captures],
'count': len(captures),
})
@subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
capture = manager.get_capture(capture_id)
if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
path = manager.get_capture_path(capture_id)
if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return send_file(
path,
mimetype='application/octet-stream',
as_attachment=True,
download_name=path.name,
)
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
label = data.get('label', '')
if label is None:
label = ''
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
result = manager.trim_capture(
capture_id=capture_id,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
label=label,
)
if result.get('status') == 'ok':
return jsonify(result), 200
message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
# ------------------------------------------------------------------
# SSE STREAM
# ------------------------------------------------------------------
@subghz_bp.route('/stream')
def stream():
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+626
View File
@@ -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
+194 -3
View File
@@ -214,9 +214,12 @@ check_tools() {
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_optional "hackrf_transfer" "HackRF SubGHz transceiver" hackrf_transfer
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
@@ -568,10 +571,43 @@ install_acarsdec_from_source_macos() {
|| { warn "Failed to clone acarsdec"; exit 1; }
cd "$tmp_dir/acarsdec"
# Fix compiler flags for macOS Apple Silicon (ARM64)
# -march=native can fail with Apple Clang on M-series chips
# -Ofast is deprecated in modern Clang
if [[ "$(uname -m)" == "arm64" ]]; then
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
info "Patched compiler flags for Apple Silicon (arm64)"
fi
# Fix pthread_tryjoin_np (Linux-only GNU extension) for macOS
# Replace with pthread_join which provides equivalent behavior
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
sed -i '' 's/pthread_tryjoin_np(\([^,]*\), NULL)/pthread_join(\1, NULL)/g' rtl.c
info "Patched pthread_tryjoin_np for macOS compatibility"
fi
# Fix libacars linking on macOS (upstream issue #112)
# Use LIBACARS_LINK_LIBRARIES (full path) instead of LIBACARS_LIBRARIES (name only)
if grep -q 'LIBACARS_LIBRARIES' CMakeLists.txt 2>/dev/null; then
sed -i '' 's/${LIBACARS_LIBRARIES}/${LIBACARS_LINK_LIBRARIES}/g' CMakeLists.txt
info "Patched libacars linking for macOS"
fi
mkdir -p build && cd build
# Set Homebrew paths for Apple Silicon (/opt/homebrew) or Intel (/usr/local)
HOMEBREW_PREFIX="$(brew --prefix)"
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
info "Compiling acarsdec..."
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
build_log="$tmp_dir/acarsdec-build.log"
if cmake .. -Drtl=ON \
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
>"$build_log" 2>&1 \
&& make >>"$build_log" 2>&1; then
if [[ -w /usr/local/bin ]]; then
install -m 0755 acarsdec /usr/local/bin/acarsdec
else
@@ -580,6 +616,8 @@ install_acarsdec_from_source_macos() {
ok "acarsdec installed successfully from source"
else
warn "Failed to build acarsdec. ACARS decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi
)
}
@@ -617,8 +655,129 @@ install_aiscatcher_from_source_macos() {
)
}
install_satdump_from_source_debian() {
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
apt_install build-essential git cmake pkg-config \
libpng-dev libtiff-dev libjemalloc-dev libvolk-dev libnng-dev \
libzstd-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev \
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning SatDump v1.2.2..."
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|| { warn "Failed to clone SatDump"; exit 1; }
cd "$tmp_dir/SatDump"
mkdir -p build && cd build
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
build_log="$tmp_dir/satdump-build.log"
# Show periodic progress while building so the user knows it's not hung
(
while true; do
sleep 30
if [ -f "$build_log" ]; then
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
fi
done
) &
progress_pid=$!
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >"$build_log" 2>&1 \
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
$SUDO make install >/dev/null 2>&1
$SUDO ldconfig
# Ensure plugins are in the expected path (handles multiarch differences)
$SUDO mkdir -p /usr/local/lib/satdump/plugins
if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then
$SUDO ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/
break
fi
done
fi
ok "SatDump installed successfully."
else
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi
)
}
install_satdump_from_source_macos() {
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
brew_install cmake
brew_install libpng
brew_install libtiff
brew_install jemalloc
brew_install libvolk
brew_install nng
brew_install zstd
brew_install soapysdr
brew_install hackrf
brew_install fftw
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning SatDump v1.2.2..."
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|| { warn "Failed to clone SatDump"; exit 1; }
cd "$tmp_dir/SatDump"
mkdir -p build && cd build
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
build_log="$tmp_dir/satdump-build.log"
# Show periodic progress while building so the user knows it's not hung
(
while true; do
sleep 30
if [ -f "$build_log" ]; then
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
fi
done
) &
progress_pid=$!
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >"$build_log" 2>&1 \
&& make -j "$(sysctl -n hw.ncpu)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
if [[ -w /usr/local/bin ]]; then
make install >/dev/null 2>&1
else
sudo make install >/dev/null 2>&1
fi
ok "SatDump installed successfully."
else
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
warn "Build log (last 30 lines):"
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
fi
)
}
install_macos_packages() {
TOTAL_STEPS=18
TOTAL_STEPS=19
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -660,6 +819,9 @@ install_macos_packages() {
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing HackRF tools"
brew_install hackrf
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
@@ -695,6 +857,19 @@ install_macos_packages() {
ok "AIS-catcher already installed"
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"
brew_install aircrack-ng
@@ -992,7 +1167,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=25
TOTAL_STEPS=26
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -1070,6 +1245,9 @@ install_debian_packages() {
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing HackRF tools"
apt_install hackrf || warn "hackrf tools not available"
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
@@ -1149,6 +1327,19 @@ install_debian_packages() {
ok "AIS-catcher already installed"
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"
setup_udev_rules_debian
+121 -3
View File
@@ -1448,6 +1448,7 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
overflow: hidden;
position: relative;
}
@media (min-width: 1024px) {
@@ -1457,6 +1458,18 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
}
.main-content.sidebar-collapsed {
grid-template-columns: 0 1fr;
}
.main-content.sidebar-collapsed > .sidebar {
width: 0;
min-width: 0;
padding: 0;
border-right: 0;
overflow: hidden;
}
}
.sidebar {
@@ -1480,6 +1493,63 @@ header h1 .tagline {
display: none;
}
.sidebar-collapse-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sidebar-collapse-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.sidebar-expand-handle {
display: none;
position: absolute;
top: 12px;
left: 10px;
z-index: 12;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--accent-cyan);
border-radius: 6px;
cursor: pointer;
}
.main-content.sidebar-collapsed .sidebar-expand-handle {
display: inline-flex;
}
/* Reserve space for the expand handle so it doesn't overlap mode titles */
.main-content.sidebar-collapsed .output-header {
padding-left: 48px;
}
@media (max-width: 1023px) {
.sidebar-collapse-btn,
.sidebar-expand-handle {
display: none !important;
}
}
.section {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -1528,8 +1598,10 @@ header h1 .tagline {
.section.collapsed h3 {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 10px;
margin-bottom: 0 !important;
min-height: 0 !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
}
.section.collapsed h3::after {
@@ -1538,7 +1610,8 @@ header h1 .tagline {
}
.section.collapsed {
padding-bottom: 0;
padding-bottom: 0 !important;
min-height: 0;
}
.section.collapsed>*:not(h3) {
@@ -2313,6 +2386,45 @@ header h1 .tagline {
display: block;
}
/* Normalize spacing for all sidebar mode panels */
.sidebar .mode-content.active:not(#meshtasticMode) {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > * {
margin: 0 !important;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .section {
margin: 0 !important;
}
.mode-actions-bottom {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .mode-actions-bottom {
margin-top: auto !important;
}
#btMessageContainer:empty {
display: none;
}
.alpha-mode-notice {
padding: 8px 10px;
border: 1px solid rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
color: var(--accent-yellow);
border-radius: 6px;
font-size: 10px;
line-height: 1.45;
}
/* Aircraft (ADS-B) Styles */
.aircraft-card {
padding: 12px;
@@ -4472,6 +4584,12 @@ header h1 .tagline {
text-overflow: ellipsis;
}
.bt-row-actions {
display: flex;
justify-content: flex-end;
padding: 4px 4px 0 42px;
}
/* Bluetooth Device Modal */
.bt-modal-overlay {
position: fixed;
+47
View File
@@ -326,3 +326,50 @@
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
/* APRS map markers (flat SVG icons) */
.aprs-map-marker-wrap {
background: transparent;
border: none;
}
.aprs-map-marker {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px 2px 5px;
border-radius: 999px;
border: 1px solid rgba(74, 158, 255, 0.35);
background: rgba(10, 18, 28, 0.88);
color: var(--text-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.aprs-map-marker-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--accent-cyan);
}
.aprs-map-marker-icon svg {
width: 14px;
height: 14px;
display: block;
fill: currentColor;
}
.aprs-map-marker-label {
font-size: 9px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
.aprs-map-marker.vehicle .aprs-map-marker-icon {
color: var(--accent-green);
}
.aprs-map-marker.tower .aprs-map-marker-icon {
color: var(--accent-cyan);
}
+430
View File
@@ -0,0 +1,430 @@
/* BT Locate Mode Styles */
/* Environment preset grid */
.btl-env-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
}
.btl-env-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
}
.btl-env-btn:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.2);
}
.btl-env-btn.active {
background: rgba(0, 255, 136, 0.1);
border-color: var(--accent-green, #00ff88);
color: var(--text-primary);
}
.btl-env-icon {
font-size: 18px;
line-height: 1;
}
.btl-env-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.btl-env-n {
font-size: 9px;
font-family: var(--font-mono);
color: var(--text-dim);
}
/* ============================================
PROXIMITY HUD main visuals area
============================================ */
.btl-hud {
display: flex;
flex-direction: column;
gap: 0;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
flex-shrink: 0;
overflow: hidden;
}
.btl-hud-top {
display: flex;
align-items: center;
gap: 20px;
padding: 14px 20px;
}
.btl-hud-band {
font-size: 22px;
font-weight: 800;
font-family: var(--font-mono);
letter-spacing: 2px;
padding: 14px 20px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 2px solid rgba(255, 255, 255, 0.1);
color: var(--text-dim);
text-align: center;
min-width: 130px;
transition: all 0.3s;
flex-shrink: 0;
}
.btl-hud-band.immediate {
color: #ef4444;
border-color: #ef4444;
background: rgba(239, 68, 68, 0.15);
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
animation: btl-pulse 1s ease-in-out infinite;
}
.btl-hud-band.near {
color: #f97316;
border-color: #f97316;
background: rgba(249, 115, 22, 0.12);
box-shadow: 0 0 15px rgba(249, 115, 22, 0.15);
animation: btl-pulse 2s ease-in-out infinite;
}
.btl-hud-band.far {
color: #eab308;
border-color: #eab308;
background: rgba(234, 179, 8, 0.1);
box-shadow: 0 0 10px rgba(234, 179, 8, 0.1);
}
@keyframes btl-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.btl-hud-metrics {
display: flex;
gap: 20px;
flex: 1;
align-items: flex-start;
}
.btl-hud-separator {
width: 1px;
height: 40px;
background: rgba(255, 255, 255, 0.08);
align-self: center;
flex-shrink: 0;
}
.btl-hud-metric {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.btl-hud-metric-lg .btl-hud-value {
font-size: 28px;
}
.btl-hud-value {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
line-height: 1.1;
}
.btl-hud-unit {
font-size: 10px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.btl-hud-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.btl-hud-controls {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.btl-hud-audio-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.btl-hud-audio-toggle input[type="checkbox"] {
margin: 0;
}
.btl-hud-clear-btn {
padding: 4px 10px;
font-size: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s;
}
.btl-hud-clear-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
}
/* Bottom info bar */
.btl-hud-bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 20px;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.btl-hud-info {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.btl-hud-info-item {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-dim);
}
.btl-hud-info-sep {
color: rgba(255, 255, 255, 0.15);
font-size: 10px;
}
.btl-hud-diag {
display: none;
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
opacity: 0.5;
white-space: nowrap;
}
.btl-hud-diag:not(:empty) {
display: block;
}
/* ============================================
VISUALS AREA map + chart
============================================ */
.btl-visuals-container {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
padding: 8px;
}
.btl-map-container {
flex: 1;
min-height: 250px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
#btLocateMap {
width: 100%;
height: 100%;
background: #1a1a2e;
}
.btl-rssi-chart-container {
height: 100px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px;
position: relative;
flex-shrink: 0;
}
.btl-rssi-chart-container .btl-chart-label {
position: absolute;
top: 4px;
left: 8px;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
#btLocateRssiChart {
width: 100%;
height: 100%;
}
/* ============================================
LOCATE BUTTON Bluetooth device cards
============================================ */
.bt-locate-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent-green, #00ff88);
background: rgba(0, 255, 136, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3);
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.bt-locate-btn:hover {
background: rgba(0, 255, 136, 0.2);
border-color: var(--accent-green, #00ff88);
}
.bt-locate-btn svg {
width: 10px;
height: 10px;
}
/* ============================================
IRK DETECT BUTTON + DEVICE PICKER
============================================ */
.btl-detect-irk-btn {
padding: 5px 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent-cyan, #00d4ff);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.btl-detect-irk-btn:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan, #00d4ff);
}
.btl-detect-irk-btn:disabled {
opacity: 0.5;
cursor: wait;
}
.btl-irk-picker {
margin-top: 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
}
.btl-irk-picker-status {
padding: 8px 10px;
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
.btl-irk-picker-list {
max-height: 160px;
overflow-y: auto;
}
.btl-irk-picker-item {
padding: 7px 10px;
cursor: pointer;
transition: background 0.15s;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.btl-irk-picker-item:first-child {
border-top: none;
}
.btl-irk-picker-item:hover {
background: rgba(0, 255, 136, 0.08);
}
.btl-irk-picker-name {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
}
.btl-irk-picker-meta {
font-size: 9px;
font-family: var(--font-mono);
color: var(--text-dim);
margin-top: 1px;
}
/* ============================================
RESPONSIVE stack HUD vertically on narrow
============================================ */
@media (max-width: 900px) {
.btl-hud {
flex-wrap: wrap;
gap: 10px;
}
.btl-hud-band {
min-width: unset;
width: 100%;
font-size: 20px;
}
.btl-hud-metrics {
width: 100%;
justify-content: space-around;
}
.btl-hud-controls {
flex-direction: row;
width: 100%;
justify-content: center;
}
}
+332
View File
@@ -0,0 +1,332 @@
/* GPS Mode Styles */
/* Sidebar info grid */
.gps-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.gps-info-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 3px;
}
.gps-info-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-info-value {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
.gps-mono {
font-family: var(--font-mono);
}
/* Connection status */
.gps-connection-status {
display: flex;
align-items: center;
gap: 6px;
}
.gps-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
}
.gps-status-dot.connected {
background: #00ff88;
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
}
.gps-status-dot.waiting {
background: #ffaa00;
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
}
.gps-status-text {
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
/* Fix badge */
.gps-fix-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
}
.gps-fix-badge.no-fix {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
border: 1px solid rgba(255, 68, 68, 0.3);
}
.gps-fix-badge.fix-2d {
background: rgba(255, 170, 0, 0.2);
color: #ffaa00;
border: 1px solid rgba(255, 170, 0, 0.3);
}
.gps-fix-badge.fix-3d {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
border: 1px solid rgba(0, 255, 136, 0.3);
}
/* DOP quality indicators */
.gps-dop-good { color: #00ff88; }
.gps-dop-moderate { color: #ffaa00; }
.gps-dop-poor { color: #ff4444; }
/* ===== Visuals Panel ===== */
.gps-visuals-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow-y: auto;
}
/* Top row: sky view + position info */
.gps-visuals-top {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
/* Sky View */
.gps-skyview-panel {
flex: 1;
min-width: 320px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.gps-skyview-panel h4 {
margin: 0 0 8px 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-skyview-canvas-wrap {
display: flex;
justify-content: center;
align-items: center;
}
#gpsSkyCanvas {
max-width: 100%;
height: auto;
}
/* Position info panel */
.gps-position-panel {
flex: 1;
min-width: 280px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.gps-position-panel h4 {
margin: 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-pos-big {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
color: var(--accent-cyan);
line-height: 1.3;
}
.gps-pos-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
}
.gps-pos-row:last-child {
border-bottom: none;
}
.gps-pos-label {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
}
.gps-pos-value {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
}
/* Signal Strength Bars */
.gps-signal-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
}
.gps-signal-panel h4 {
margin: 0 0 8px 0;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-signal-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 140px;
padding: 0 4px;
overflow-x: auto;
}
.gps-signal-bar-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 18px;
height: 100%;
justify-content: flex-end;
}
.gps-signal-bar {
width: 14px;
border-radius: 2px 2px 0 0;
min-height: 2px;
transition: height 0.3s ease;
}
.gps-signal-bar.unused {
opacity: 0.4;
}
.gps-signal-prn {
font-size: 8px;
font-family: var(--font-mono);
color: var(--text-dim);
writing-mode: horizontal-tb;
}
.gps-signal-snr {
font-size: 7px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
/* Constellation colors */
.gps-const-gps { background-color: #00d4ff; }
.gps-const-glonass { background-color: #00ff88; }
.gps-const-galileo { background-color: #ff8800; }
.gps-const-beidou { background-color: #ff4466; }
.gps-const-sbas { background-color: #ffdd00; }
.gps-const-qzss { background-color: #cc66ff; }
/* Legend */
.gps-legend {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.gps-legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: var(--text-dim);
}
.gps-legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Empty state */
.gps-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
color: var(--text-dim);
text-align: center;
}
.gps-empty-state svg {
width: 48px;
height: 48px;
opacity: 0.3;
}
.gps-empty-state p {
font-size: 12px;
max-width: 300px;
line-height: 1.5;
}
/* Responsive */
@media (max-width: 768px) {
.gps-visuals-top {
flex-direction: column;
}
.gps-skyview-panel,
.gps-position-panel {
min-width: unset;
}
.gps-pos-big {
font-size: 16px;
}
}
+3 -1
View File
@@ -340,7 +340,9 @@
MODE VISIBILITY - Ensure sidebar shows when active
============================================ */
#spystationsMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvGeneralMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -114,7 +114,7 @@
position: fixed;
top: 0;
left: 0;
width: min(320px, 85vw);
width: min(360px, 100vw);
height: 100dvh;
height: 100vh; /* Fallback */
background: var(--bg-secondary, #0f1218);
@@ -381,6 +381,33 @@
-webkit-overflow-scrolling: touch;
}
.sidebar {
padding: 10px;
gap: 10px;
}
.output-panel {
min-height: 58vh;
}
.output-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-controls {
width: 100%;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.header-controls .stats {
min-width: max-content;
}
/* Container should not clip content */
.container {
overflow: visible;
+28 -4
View File
@@ -24,7 +24,7 @@
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
max-width: 900px;
width: 100%;
max-height: calc(100vh - 80px);
display: flex;
@@ -74,22 +74,28 @@
/* Settings Tabs */
.settings-tabs {
display: flex;
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
gap: 0;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
padding: 12px 10px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-tab:hover {
@@ -474,6 +480,12 @@
}
/* Responsive */
@media (max-width: 960px) {
.settings-tabs {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
@@ -483,6 +495,18 @@
max-width: 100%;
}
.settings-tabs {
padding: 0 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-tab {
padding: 10px 6px;
font-size: 11px;
white-space: normal;
line-height: 1.2;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
+5 -3
View File
@@ -488,10 +488,12 @@ function initApp() {
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
if (index > 0) {
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.sidebar .section').forEach((section) => {
if (section.querySelector('h3')) {
section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
}
});
+8 -1
View File
@@ -1,7 +1,9 @@
// Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config.
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 AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
@@ -41,6 +43,10 @@ window.ObserverLocation = (function() {
return normalize(lat, lon);
}
function hasStoredLocation() {
return !!(readKey(SHARED_KEY) || readKey(AIS_KEY) || readLegacyLatLon());
}
function getShared() {
const current = readKey(SHARED_KEY);
if (current) return current;
@@ -93,6 +99,7 @@ window.ObserverLocation = (function() {
return {
isSharedEnabled,
hasStoredLocation,
getShared,
setShared,
getForModule,
+118 -75
View File
@@ -366,10 +366,10 @@ const BluetoothMode = (function() {
// Badges
const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
// Tracker badge
if (device.is_tracker) {
@@ -451,14 +451,14 @@ const BluetoothMode = (function() {
? minMax[0] + '/' + minMax[1]
: '--';
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
updateWatchlistButton(device);
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
updateWatchlistButton(device);
// Services
const servicesContainer = document.getElementById('btDetailServices');
@@ -470,29 +470,29 @@ const BluetoothMode = (function() {
servicesContainer.style.display = 'none';
}
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Highlight selected device in list
highlightSelectedDevice(deviceId);
}
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
}
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
/**
* Clear device selection
@@ -546,43 +546,43 @@ const BluetoothMode = (function() {
/**
* Copy selected device address to clipboard
*/
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.getElementById('btDetailCopyBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.getElementById('btDetailCopyBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
}
});
}
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
});
}
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
/**
* Select a device - opens modal with details
@@ -1130,11 +1130,11 @@ const BluetoothMode = (function() {
const isNew = !inBaseline;
const hasName = !!device.name;
const isTracker = device.is_tracker === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
// Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong)
@@ -1186,9 +1186,9 @@ const BluetoothMode = (function() {
// Build secondary info line
let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
@@ -1216,6 +1216,11 @@ const BluetoothMode = (function() {
'</div>' +
'</div>' +
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
'<div class="bt-row-actions">' +
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
'Locate</button>' +
'</div>' +
'</div>';
}
@@ -1391,6 +1396,42 @@ const BluetoothMode = (function() {
updateRadar();
}
/**
* Hand off a device to BT Locate mode by device_id lookup.
*/
function locateById(deviceId) {
console.log('[BT] locateById called with:', deviceId);
const device = devices.get(deviceId);
if (!device) {
console.warn('[BT] Device not found in map for id:', deviceId);
return;
}
doLocateHandoff(device);
}
/**
* Hand off the currently selected device to BT Locate mode.
*/
function locateDevice() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
doLocateHandoff(device);
}
function doLocateHandoff(device) {
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
if (typeof BtLocate !== 'undefined') {
BtLocate.handoff({
device_id: device.device_id,
mac_address: device.address,
known_name: device.name || null,
known_manufacturer: device.manufacturer_name || null,
last_known_rssi: device.rssi_current
});
}
}
// Public API
return {
init,
@@ -1400,10 +1441,12 @@ const BluetoothMode = (function() {
setBaseline,
clearBaseline,
exportData,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
locateDevice,
locateById,
// Agent handling
handleAgentChange,
+732
View File
@@ -0,0 +1,732 @@
/**
* BT Locate Bluetooth SAR Device Location Mode
* GPS-tagged signal trail mapping with proximity audio alerts.
*/
const BtLocate = (function() {
'use strict';
let eventSource = null;
let map = null;
let mapMarkers = [];
let trailLine = null;
let rssiHistory = [];
const MAX_RSSI_POINTS = 60;
let chartCanvas = null;
let chartCtx = null;
let currentEnvironment = 'OUTDOOR';
let audioCtx = null;
let audioEnabled = false;
let beepTimer = null;
let initialized = false;
let handoffData = null;
let pollTimer = null;
let durationTimer = null;
let sessionStartedAt = null;
let lastDetectionCount = 0;
function init() {
if (initialized) {
// Re-invalidate map on re-entry and ensure tiles are present
if (map) {
setTimeout(() => {
map.invalidateSize();
// Re-apply user's tile layer if tiles were lost
let hasTiles = false;
map.eachLayer(layer => {
if (layer instanceof L.TileLayer) hasTiles = true;
});
if (!hasTiles && typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(map);
}
}, 150);
}
checkStatus();
return;
}
// Init map
const mapEl = document.getElementById('btLocateMap');
if (mapEl && typeof L !== 'undefined') {
map = L.map('btLocateMap', {
center: [0, 0],
zoom: 2,
zoomControl: true,
});
// Use tile provider from user settings
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(map);
Settings.registerMap(map);
} else {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '&copy; OSM &copy; CARTO'
}).addTo(map);
}
setTimeout(() => map.invalidateSize(), 100);
}
// Init RSSI chart canvas
chartCanvas = document.getElementById('btLocateRssiChart');
if (chartCanvas) {
chartCtx = chartCanvas.getContext('2d');
}
checkStatus();
initialized = true;
}
function checkStatus() {
fetch('/bt_locate/status')
.then(r => r.json())
.then(data => {
if (data.active) {
sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
showActiveUI();
updateScanStatus(data);
if (!eventSource) connectSSE();
// Restore trail from server
fetch('/bt_locate/trail')
.then(r => r.json())
.then(trail => {
if (trail.gps_trail) {
trail.gps_trail.forEach(p => addMapMarker(p));
}
updateStats(data.detection_count, data.gps_trail_count);
});
}
})
.catch(() => {});
}
function start() {
const mac = document.getElementById('btLocateMac')?.value.trim();
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
const irk = document.getElementById('btLocateIrk')?.value.trim();
const body = { environment: currentEnvironment };
if (mac) body.mac_address = mac;
if (namePattern) body.name_pattern = namePattern;
if (irk) body.irk_hex = irk;
if (handoffData?.device_id) body.device_id = handoffData.device_id;
if (handoffData?.known_name) body.known_name = handoffData.known_name;
if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer;
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
// Include user location as fallback when GPS unavailable
const userLat = localStorage.getItem('observerLat');
const userLon = localStorage.getItem('observerLon');
if (userLat && userLon) {
body.fallback_lat = parseFloat(userLat);
body.fallback_lon = parseFloat(userLon);
}
console.log('[BtLocate] Starting with body:', body);
if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id) {
alert('Please provide at least a MAC address, name pattern, IRK, or use hand-off from Bluetooth mode.');
return;
}
fetch('/bt_locate/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
showActiveUI();
connectSSE();
rssiHistory = [];
updateScanStatus(data.session);
// Restore any existing trail (e.g. from a stop/start cycle)
restoreTrail();
}
})
.catch(err => console.error('[BtLocate] Start error:', err));
}
function stop() {
fetch('/bt_locate/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
showIdleUI();
disconnectSSE();
stopAudio();
})
.catch(err => console.error('[BtLocate] Stop error:', err));
}
function showActiveUI() {
const startBtn = document.getElementById('btLocateStartBtn');
const stopBtn = document.getElementById('btLocateStopBtn');
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) stopBtn.style.display = 'inline-block';
show('btLocateHud');
}
function showIdleUI() {
const startBtn = document.getElementById('btLocateStartBtn');
const stopBtn = document.getElementById('btLocateStopBtn');
if (startBtn) startBtn.style.display = 'inline-block';
if (stopBtn) stopBtn.style.display = 'none';
hide('btLocateHud');
hide('btLocateScanStatus');
}
function updateScanStatus(statusData) {
const el = document.getElementById('btLocateScanStatus');
const dot = document.getElementById('btLocateScanDot');
const text = document.getElementById('btLocateScanText');
if (!el) return;
el.style.display = '';
if (statusData && statusData.scanner_running) {
if (dot) dot.style.background = '#22c55e';
if (text) text.textContent = 'BT scanner active';
} else {
if (dot) dot.style.background = '#f97316';
if (text) text.textContent = 'BT scanner not running — waiting...';
}
}
function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function connectSSE() {
if (eventSource) eventSource.close();
console.log('[BtLocate] Connecting SSE stream');
eventSource = new EventSource('/bt_locate/stream');
eventSource.addEventListener('detection', function(e) {
try {
const event = JSON.parse(e.data);
console.log('[BtLocate] Detection event:', event);
handleDetection(event);
} catch (err) {
console.error('[BtLocate] Parse error:', err);
}
});
eventSource.addEventListener('session_ended', function() {
showIdleUI();
disconnectSSE();
});
eventSource.onerror = function() {
console.warn('[BtLocate] SSE error, polling fallback active');
};
// Start polling fallback (catches data even if SSE fails)
startPolling();
}
function disconnectSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
stopPolling();
}
function startPolling() {
stopPolling();
lastDetectionCount = 0;
pollTimer = setInterval(pollStatus, 3000);
startDurationTimer();
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
stopDurationTimer();
}
function startDurationTimer() {
stopDurationTimer();
durationTimer = setInterval(updateDuration, 1000);
}
function stopDurationTimer() {
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
}
function updateDuration() {
if (!sessionStartedAt) return;
const elapsed = Math.round((Date.now() - sessionStartedAt) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timeEl = document.getElementById('btLocateSessionTime');
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
}
function pollStatus() {
fetch('/bt_locate/status')
.then(r => r.json())
.then(data => {
if (!data.active) {
showIdleUI();
disconnectSSE();
return;
}
updateScanStatus(data);
updateHudInfo(data);
// Show diagnostics
const diagEl = document.getElementById('btLocateDiag');
if (diagEl) {
let diag = 'Polls: ' + (data.poll_count || 0) +
(data.poll_thread_alive === false ? ' DEAD' : '') +
' | Scan: ' + (data.scanner_running ? 'Y' : 'N') +
' | Devices: ' + (data.scanner_device_count || 0) +
' | Det: ' + (data.detection_count || 0);
// Show debug device sample if no detections
if (data.detection_count === 0 && data.debug_devices && data.debug_devices.length > 0) {
const matched = data.debug_devices.filter(d => d.match);
const sample = data.debug_devices.slice(0, 3).map(d =>
(d.name || '?') + '|' + (d.id || '').substring(0, 12) + ':' + (d.match ? 'Y' : 'N')
).join(', ');
diag += ' | Match:' + matched.length + '/' + data.debug_devices.length + ' [' + sample + ']';
}
diagEl.textContent = diag;
}
// If detection count increased, fetch new trail points
if (data.detection_count > lastDetectionCount) {
lastDetectionCount = data.detection_count;
fetch('/bt_locate/trail')
.then(r => r.json())
.then(trail => {
if (trail.trail && trail.trail.length > 0) {
const latest = trail.trail[trail.trail.length - 1];
handleDetection({ data: latest });
}
updateStats(data.detection_count, data.gps_trail_count);
});
}
})
.catch(() => {});
}
function updateHudInfo(data) {
// Target info
const targetEl = document.getElementById('btLocateTargetInfo');
if (targetEl && data.target) {
const t = data.target;
const name = t.known_name || t.name_pattern || '';
const addr = t.mac_address || t.device_id || '';
targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--';
}
// Environment info
const envEl = document.getElementById('btLocateEnvInfo');
if (envEl) {
const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' };
envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?');
}
// GPS status
const gpsEl = document.getElementById('btLocateGpsStatus');
if (gpsEl) {
const src = data.gps_source || 'none';
if (src === 'live') gpsEl.textContent = 'GPS: Live';
else if (src === 'manual') gpsEl.textContent = 'GPS: Manual';
else gpsEl.textContent = 'GPS: None';
}
// Last seen
const lastEl = document.getElementById('btLocateLastSeen');
if (lastEl) {
if (data.last_detection) {
const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000);
lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago');
} else {
lastEl.textContent = 'Last: --';
}
}
// Session start time (duration handled by 1s timer)
if (data.started_at && !sessionStartedAt) {
sessionStartedAt = new Date(data.started_at).getTime();
}
}
function handleDetection(event) {
const d = event.data;
if (!d) return;
// Update proximity UI
const bandEl = document.getElementById('btLocateBand');
const distEl = document.getElementById('btLocateDistance');
const rssiEl = document.getElementById('btLocateRssi');
const rssiEmaEl = document.getElementById('btLocateRssiEma');
if (bandEl) {
bandEl.textContent = d.proximity_band;
bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase();
}
if (distEl) distEl.textContent = d.estimated_distance.toFixed(1);
if (rssiEl) rssiEl.textContent = d.rssi;
if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
// RSSI sparkline
rssiHistory.push(d.rssi);
if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
drawRssiChart();
// Map marker
if (d.lat != null && d.lon != null) {
addMapMarker(d);
}
// Update stats
const detCountEl = document.getElementById('btLocateDetectionCount');
const gpsCountEl = document.getElementById('btLocateGpsCount');
if (detCountEl) {
const cur = parseInt(detCountEl.textContent) || 0;
detCountEl.textContent = cur + 1;
}
if (gpsCountEl && d.lat != null) {
const cur = parseInt(gpsCountEl.textContent) || 0;
gpsCountEl.textContent = cur + 1;
}
// Audio
if (audioEnabled) playProximityTone(d.rssi);
}
function updateStats(detections, gpsPoints) {
const detCountEl = document.getElementById('btLocateDetectionCount');
const gpsCountEl = document.getElementById('btLocateGpsCount');
if (detCountEl) detCountEl.textContent = detections || 0;
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
}
function addMapMarker(point) {
if (!map || point.lat == null || point.lon == null) return;
const band = (point.proximity_band || 'FAR').toLowerCase();
const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
const sizes = { immediate: 8, near: 6, far: 5 };
const color = colors[band] || '#eab308';
const radius = sizes[band] || 5;
const marker = L.circleMarker([point.lat, point.lon], {
radius: radius,
fillColor: color,
color: '#fff',
weight: 1,
opacity: 0.9,
fillOpacity: 0.8,
}).addTo(map);
marker.bindPopup(
'<div style="font-family:monospace;font-size:11px;">' +
'<b>' + point.proximity_band + '</b><br>' +
'RSSI: ' + point.rssi + ' dBm<br>' +
'Distance: ~' + point.estimated_distance.toFixed(1) + ' m<br>' +
'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
'</div>'
);
mapMarkers.push(marker);
map.panTo([point.lat, point.lon]);
// Update trail line
const latlngs = mapMarkers.map(m => m.getLatLng());
if (trailLine) {
trailLine.setLatLngs(latlngs);
} else if (latlngs.length >= 2) {
trailLine = L.polyline(latlngs, {
color: 'rgba(0,255,136,0.5)',
weight: 2,
dashArray: '4 4',
}).addTo(map);
}
}
function restoreTrail() {
fetch('/bt_locate/trail')
.then(r => r.json())
.then(trail => {
if (trail.gps_trail && trail.gps_trail.length > 0) {
clearMapMarkers();
trail.gps_trail.forEach(p => addMapMarker(p));
}
if (trail.trail && trail.trail.length > 0) {
// Restore RSSI history from trail
rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
drawRssiChart();
// Update HUD with latest detection
const latest = trail.trail[trail.trail.length - 1];
handleDetection({ data: latest });
}
})
.catch(() => {});
}
function clearMapMarkers() {
mapMarkers.forEach(m => map?.removeLayer(m));
mapMarkers = [];
if (trailLine) {
map?.removeLayer(trailLine);
trailLine = null;
}
}
function drawRssiChart() {
if (!chartCtx || !chartCanvas) return;
const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16;
const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24;
chartCtx.clearRect(0, 0, w, h);
if (rssiHistory.length < 2) return;
// RSSI range: -100 to -20
const minR = -100, maxR = -20;
const range = maxR - minR;
// Grid lines
chartCtx.strokeStyle = 'rgba(255,255,255,0.05)';
chartCtx.lineWidth = 1;
[-30, -50, -70, -90].forEach(v => {
const y = h - ((v - minR) / range) * h;
chartCtx.beginPath();
chartCtx.moveTo(0, y);
chartCtx.lineTo(w, y);
chartCtx.stroke();
});
// Draw RSSI line
const step = w / (MAX_RSSI_POINTS - 1);
chartCtx.beginPath();
chartCtx.strokeStyle = '#00ff88';
chartCtx.lineWidth = 2;
rssiHistory.forEach((rssi, i) => {
const x = i * step;
const y = h - ((rssi - minR) / range) * h;
if (i === 0) chartCtx.moveTo(x, y);
else chartCtx.lineTo(x, y);
});
chartCtx.stroke();
// Fill under
const lastIdx = rssiHistory.length - 1;
chartCtx.lineTo(lastIdx * step, h);
chartCtx.lineTo(0, h);
chartCtx.closePath();
chartCtx.fillStyle = 'rgba(0,255,136,0.08)';
chartCtx.fill();
}
// Audio proximity tone (Web Audio API)
function playTone(freq, duration) {
if (!audioCtx || audioCtx.state !== 'running') return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.frequency.value = freq;
osc.type = 'sine';
gain.gain.value = 0.2;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playProximityTone(rssi) {
if (!audioCtx || audioCtx.state !== 'running') return;
// Stronger signal = higher pitch and shorter beep
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
const freq = 400 + strength * 800; // 400-1200 Hz
const duration = 0.06 + (1 - strength) * 0.12;
playTone(freq, duration);
}
function toggleAudio() {
const cb = document.getElementById('btLocateAudioEnable');
audioEnabled = cb?.checked || false;
if (audioEnabled) {
// Create AudioContext on user gesture (required by browser policy)
if (!audioCtx) {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.error('[BtLocate] AudioContext creation failed:', e);
return;
}
}
// Resume must happen within a user gesture handler
const ctx = audioCtx;
ctx.resume().then(() => {
console.log('[BtLocate] AudioContext state:', ctx.state);
// Confirmation beep so user knows audio is working
playTone(600, 0.08);
});
} else {
stopAudio();
}
}
function stopAudio() {
audioEnabled = false;
const cb = document.getElementById('btLocateAudioEnable');
if (cb) cb.checked = false;
}
function setEnvironment(env) {
currentEnvironment = env;
document.querySelectorAll('.btl-env-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.env === env);
});
// Push to running session if active
fetch('/bt_locate/status').then(r => r.json()).then(data => {
if (data.active) {
fetch('/bt_locate/environment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment: env }),
}).then(r => r.json()).then(res => {
console.log('[BtLocate] Environment updated:', res);
});
}
}).catch(() => {});
}
function handoff(deviceInfo) {
console.log('[BtLocate] Handoff received:', deviceInfo);
handoffData = deviceInfo;
// Populate fields
if (deviceInfo.mac_address) {
const macInput = document.getElementById('btLocateMac');
if (macInput) macInput.value = deviceInfo.mac_address;
}
// Show handoff card
const card = document.getElementById('btLocateHandoffCard');
const nameEl = document.getElementById('btLocateHandoffName');
const metaEl = document.getElementById('btLocateHandoffMeta');
if (card) card.style.display = '';
if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown';
if (metaEl) {
const parts = [];
if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address);
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
metaEl.textContent = parts.join(' \u00b7 ');
}
// Switch to bt_locate mode
if (typeof switchMode === 'function') {
switchMode('bt_locate');
}
}
function clearHandoff() {
handoffData = null;
const card = document.getElementById('btLocateHandoffCard');
if (card) card.style.display = 'none';
}
function fetchPairedIrks() {
const picker = document.getElementById('btLocateIrkPicker');
const status = document.getElementById('btLocateIrkPickerStatus');
const list = document.getElementById('btLocateIrkPickerList');
const btn = document.getElementById('btLocateDetectIrkBtn');
if (!picker || !status || !list) return;
// Toggle off if already visible
if (picker.style.display !== 'none') {
picker.style.display = 'none';
return;
}
picker.style.display = '';
list.innerHTML = '';
status.textContent = 'Scanning paired devices...';
status.style.display = '';
if (btn) btn.disabled = true;
fetch('/bt_locate/paired_irks')
.then(r => r.json())
.then(data => {
if (btn) btn.disabled = false;
const devices = data.devices || [];
if (devices.length === 0) {
status.textContent = 'No paired devices with IRKs found';
return;
}
status.style.display = 'none';
list.innerHTML = '';
devices.forEach(dev => {
const item = document.createElement('div');
item.className = 'btl-irk-picker-item';
item.innerHTML =
'<div class="btl-irk-picker-name">' + (dev.name || 'Unknown Device') + '</div>' +
'<div class="btl-irk-picker-meta">' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '</div>';
item.addEventListener('click', function() {
selectPairedIrk(dev);
});
list.appendChild(item);
});
})
.catch(err => {
if (btn) btn.disabled = false;
console.error('[BtLocate] Failed to fetch paired IRKs:', err);
status.textContent = 'Failed to read paired devices';
});
}
function selectPairedIrk(dev) {
const irkInput = document.getElementById('btLocateIrk');
const nameInput = document.getElementById('btLocateNamePattern');
const picker = document.getElementById('btLocateIrkPicker');
if (irkInput) irkInput.value = dev.irk_hex;
if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name;
if (picker) picker.style.display = 'none';
}
function clearTrail() {
fetch('/bt_locate/clear_trail', { method: 'POST' })
.then(r => r.json())
.then(() => {
clearMapMarkers();
rssiHistory = [];
drawRssiChart();
updateStats(0, 0);
})
.catch(err => console.error('[BtLocate] Clear trail error:', err));
}
function invalidateMap() {
if (map) map.invalidateSize();
}
return {
init,
start,
stop,
handoff,
clearHandoff,
setEnvironment,
toggleAudio,
clearTrail,
handleDetection,
invalidateMap,
fetchPairedIrks,
};
})();
+293 -2
View File
@@ -11,6 +11,13 @@ let dmrSyncCount = 0;
let dmrCallHistory = [];
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 ==============
let dmrSynthCanvas = null;
@@ -38,9 +45,17 @@ function checkDmrTools() {
const warningText = document.getElementById('dmrToolsWarningText');
if (!warning) return;
const selectedType = (typeof getSelectedSDRType === 'function')
? getSelectedSDRType()
: 'rtlsdr';
const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
if (selectedType === 'rtlsdr') {
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
} else if (!data.rx_fm) {
missing.push('rx_fm (SoapySDR demodulator)');
}
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
if (missing.length > 0) {
warning.style.display = 'block';
@@ -48,6 +63,9 @@ function checkDmrTools() {
} else {
warning.style.display = 'none';
}
// Update audio panel availability
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
})
.catch(() => {});
}
@@ -61,6 +79,7 @@ function startDmr() {
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
@@ -70,10 +89,17 @@ function startDmr() {
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc })
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
})
.then(r => r.json())
.then(data => {
@@ -94,6 +120,10 @@ function startDmr() {
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') {
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
}
@@ -122,6 +152,7 @@ function startDmr() {
}
function stopDmr() {
stopDmrAudio();
fetch('/dmr/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
@@ -131,6 +162,7 @@ function stopDmr() {
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
updateDmrAudioStatus('OFF');
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') {
@@ -231,10 +263,12 @@ function handleDmrMessage(msg) {
if (statusEl) statusEl.textContent = 'DECODING';
} else if (msg.text === 'crashed') {
isDmrRunning = false;
stopDmrAudio();
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})`;
@@ -243,10 +277,12 @@ function handleDmrMessage(msg) {
}
} 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);
}
@@ -519,6 +555,249 @@ function stopDmrSynthesizer() {
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() {
@@ -552,6 +831,13 @@ function checkDmrStatus() {
.catch(() => {});
}
// ============== INIT ==============
document.addEventListener('DOMContentLoaded', () => {
restoreDmrSettings();
loadDmrBookmarks();
});
// ============== EXPORTS ==============
window.startDmr = startDmr;
@@ -559,3 +845,8 @@ window.stopDmr = stopDmr;
window.checkDmrTools = checkDmrTools;
window.checkDmrStatus = checkDmrStatus;
window.initDmrSynthesizer = initDmrSynthesizer;
window.setDmrAudioVolume = setDmrAudioVolume;
window.addDmrBookmark = addDmrBookmark;
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
window.removeDmrBookmark = removeDmrBookmark;
window.dmrQuickTune = dmrQuickTune;
+401
View File
@@ -0,0 +1,401 @@
/**
* GPS Mode
* Live GPS data display with satellite sky view, signal strength bars,
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
*/
const GPS = (function() {
let eventSource = null;
let connected = false;
let lastPosition = null;
let lastSky = null;
// Constellation color map
const CONST_COLORS = {
'GPS': '#00d4ff',
'GLONASS': '#00ff88',
'Galileo': '#ff8800',
'BeiDou': '#ff4466',
'SBAS': '#ffdd00',
'QZSS': '#cc66ff',
};
function init() {
drawEmptySkyView();
connect();
}
function connect() {
fetch('/gps/auto-connect', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.status === 'connected') {
connected = true;
updateConnectionUI(true, data.has_fix);
if (data.position) {
lastPosition = data.position;
updatePositionUI(data.position);
}
if (data.sky) {
lastSky = data.sky;
updateSkyUI(data.sky);
}
startStream();
} else {
connected = false;
updateConnectionUI(false);
}
})
.catch(() => {
connected = false;
updateConnectionUI(false);
});
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
fetch('/gps/stop', { method: 'POST' })
.then(() => {
connected = false;
updateConnectionUI(false);
});
}
function startStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/gps/stream');
eventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'position') {
lastPosition = data;
updatePositionUI(data);
updateConnectionUI(true, true);
} else if (data.type === 'sky') {
lastSky = data;
updateSkyUI(data);
}
} catch (err) {
// ignore parse errors
}
};
eventSource.onerror = function() {
// Reconnect handled by browser automatically
};
}
// ========================
// UI Updates
// ========================
function updateConnectionUI(isConnected, hasFix) {
const dot = document.getElementById('gpsStatusDot');
const text = document.getElementById('gpsStatusText');
const connectBtn = document.getElementById('gpsConnectBtn');
const disconnectBtn = document.getElementById('gpsDisconnectBtn');
const devicePath = document.getElementById('gpsDevicePath');
if (dot) {
dot.className = 'gps-status-dot';
if (isConnected && hasFix) dot.classList.add('connected');
else if (isConnected) dot.classList.add('waiting');
}
if (text) {
if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
else if (isConnected) text.textContent = 'Connected (No Fix)';
else text.textContent = 'Disconnected';
}
if (connectBtn) connectBtn.style.display = isConnected ? 'none' : '';
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
}
function updatePositionUI(pos) {
// Sidebar fields
setText('gpsLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
setText('gpsLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
setText('gpsAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
setText('gpsSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
setText('gpsHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
setText('gpsClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
// Fix type
const fixEl = document.getElementById('gpsFixType');
if (fixEl) {
const fq = pos.fix_quality;
if (fq === 3) fixEl.innerHTML = '<span class="gps-fix-badge fix-3d">3D FIX</span>';
else if (fq === 2) fixEl.innerHTML = '<span class="gps-fix-badge fix-2d">2D FIX</span>';
else fixEl.innerHTML = '<span class="gps-fix-badge no-fix">NO FIX</span>';
}
// Error estimates
const eph = (pos.epx != null && pos.epy != null) ? Math.sqrt(pos.epx * pos.epx + pos.epy * pos.epy) : null;
setText('gpsEph', eph != null ? eph.toFixed(1) + ' m' : '---');
setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---');
setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---');
// GPS time
if (pos.timestamp) {
const t = new Date(pos.timestamp);
setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
}
// Visuals: position panel
setText('gpsVisPosLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---');
setText('gpsVisPosLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---');
setText('gpsVisPosAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---');
setText('gpsVisPosSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---');
setText('gpsVisPosHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---');
setText('gpsVisPosClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---');
// Visuals: fix badge
const visFixEl = document.getElementById('gpsVisFixBadge');
if (visFixEl) {
const fq = pos.fix_quality;
if (fq === 3) { visFixEl.textContent = '3D FIX'; visFixEl.className = 'gps-fix-badge fix-3d'; }
else if (fq === 2) { visFixEl.textContent = '2D FIX'; visFixEl.className = 'gps-fix-badge fix-2d'; }
else { visFixEl.textContent = 'NO FIX'; visFixEl.className = 'gps-fix-badge no-fix'; }
}
// Visuals: GPS time
if (pos.timestamp) {
const t = new Date(pos.timestamp);
setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
}
}
function updateSkyUI(sky) {
// Sidebar sat counts
setText('gpsSatUsed', sky.usat != null ? sky.usat : '-');
setText('gpsSatTotal', sky.nsat != null ? sky.nsat : '-');
// DOP values
setDop('gpsHdop', sky.hdop);
setDop('gpsVdop', sky.vdop);
setDop('gpsPdop', sky.pdop);
setDop('gpsTdop', sky.tdop);
setDop('gpsGdop', sky.gdop);
// Visuals
drawSkyView(sky.satellites || []);
drawSignalBars(sky.satellites || []);
}
function setDop(id, val) {
const el = document.getElementById(id);
if (!el) return;
if (val == null) { el.textContent = '---'; el.className = 'gps-info-value gps-mono'; return; }
el.textContent = val.toFixed(1);
let cls = 'gps-info-value gps-mono ';
if (val <= 2) cls += 'gps-dop-good';
else if (val <= 5) cls += 'gps-dop-moderate';
else cls += 'gps-dop-poor';
el.className = cls;
}
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
// ========================
// Sky View Polar Plot
// ========================
function drawEmptySkyView() {
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
drawSkyViewBase(canvas);
}
function drawSkyView(satellites) {
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
drawSkyViewBase(canvas);
// Plot satellites
satellites.forEach(sat => {
if (sat.elevation == null || sat.azimuth == null) return;
const elRad = (90 - sat.elevation) / 90;
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
const px = cx + r * elRad * Math.cos(azRad);
const py = cy + r * elRad * Math.sin(azRad);
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
const dotSize = sat.used ? 6 : 4;
// Draw dot
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
if (sat.used) {
ctx.fillStyle = color;
ctx.fill();
} else {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// PRN label
ctx.fillStyle = color;
ctx.font = '8px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sat.prn, px, py - dotSize - 2);
// SNR value
if (sat.snr != null) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '7px JetBrains Mono, monospace';
ctx.textBaseline = 'top';
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
}
});
}
function drawSkyViewBase(canvas) {
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
ctx.clearRect(0, 0, w, h);
// Background
const bgStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-card').trim();
ctx.fillStyle = bgStyle || '#0d1117';
ctx.fillRect(0, 0, w, h);
// Elevation rings (0, 30, 60, 90)
ctx.strokeStyle = '#2a3040';
ctx.lineWidth = 0.5;
[90, 60, 30].forEach(el => {
const gr = r * (1 - el / 90);
ctx.beginPath();
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
ctx.stroke();
// Label
ctx.fillStyle = '#555';
ctx.font = '9px JetBrains Mono, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
});
// Horizon circle
ctx.strokeStyle = '#3a4050';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Cardinal directions
ctx.fillStyle = '#888';
ctx.font = 'bold 11px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', cx, cy - r - 12);
ctx.fillText('S', cx, cy + r + 12);
ctx.fillText('E', cx + r + 12, cy);
ctx.fillText('W', cx - r - 12, cy);
// Crosshairs
ctx.strokeStyle = '#2a3040';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx, cy + r);
ctx.moveTo(cx - r, cy);
ctx.lineTo(cx + r, cy);
ctx.stroke();
// Zenith dot
ctx.fillStyle = '#333';
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
ctx.fill();
}
// ========================
// Signal Strength Bars
// ========================
function drawSignalBars(satellites) {
const container = document.getElementById('gpsSignalBars');
if (!container) return;
container.innerHTML = '';
if (satellites.length === 0) return;
// Sort: used first, then by PRN
const sorted = [...satellites].sort((a, b) => {
if (a.used !== b.used) return a.used ? -1 : 1;
return a.prn - b.prn;
});
const maxSnr = 50; // dB-Hz typical max for display
sorted.forEach(sat => {
const snr = sat.snr || 0;
const heightPct = Math.min(snr / maxSnr * 100, 100);
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
const constClass = 'gps-const-' + (sat.constellation || 'GPS').toLowerCase();
const wrap = document.createElement('div');
wrap.className = 'gps-signal-bar-wrap';
const snrLabel = document.createElement('span');
snrLabel.className = 'gps-signal-snr';
snrLabel.textContent = snr > 0 ? Math.round(snr) : '';
const bar = document.createElement('div');
bar.className = 'gps-signal-bar ' + constClass + (sat.used ? '' : ' unused');
bar.style.height = Math.max(heightPct, 2) + '%';
bar.title = `PRN ${sat.prn} (${sat.constellation}) - ${Math.round(snr)} dB-Hz${sat.used ? ' [USED]' : ''}`;
const prn = document.createElement('span');
prn.className = 'gps-signal-prn';
prn.textContent = sat.prn;
wrap.appendChild(snrLabel);
wrap.appendChild(bar);
wrap.appendChild(prn);
container.appendChild(wrap);
});
}
// ========================
// Cleanup
// ========================
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
return {
init: init,
connect: connect,
disconnect: disconnect,
destroy: destroy,
};
})();
+29 -35
View File
@@ -1742,9 +1742,6 @@ function initSynthesizer() {
drawSynthesizer();
}
// Debug: log signal level periodically
let lastSynthDebugLog = 0;
function drawSynthesizer() {
if (!synthCtx || !synthCanvas) return;
@@ -1760,19 +1757,6 @@ function drawSynthesizer() {
let activityLevel = 0;
let signalIntensity = 0;
// Debug logging every 2 seconds
const now = Date.now();
if (now - lastSynthDebugLog > 2000) {
console.log('[SYNTH] State:', {
isScannerRunning,
isDirectListening,
scannerSignalActive,
currentSignalLevel,
visualizerAnalyser: !!visualizerAnalyser
});
lastSynthDebugLog = now;
}
if (isScannerRunning && !isScannerPaused) {
// Use actual signal level data (0-5000 range, normalize to 0-1)
signalIntensity = Math.min(1, currentSignalLevel / 3000);
@@ -1864,13 +1848,6 @@ function drawSynthesizer() {
synthCtx.lineTo(width, height / 2);
synthCtx.stroke();
// Debug: show signal level value
if (isScannerRunning || isDirectListening) {
synthCtx.fillStyle = 'rgba(255, 255, 255, 0.5)';
synthCtx.font = '9px monospace';
synthCtx.fillText(`lvl:${Math.round(currentSignalLevel)}`, 4, 10);
}
synthAnimationId = requestAnimationFrame(drawSynthesizer);
}
@@ -3109,7 +3086,7 @@ let waterfallEndFreq = 108;
let waterfallRowImage = null;
let waterfallPalette = null;
let lastWaterfallDraw = 0;
const WATERFALL_MIN_INTERVAL_MS = 50;
const WATERFALL_MIN_INTERVAL_MS = 200;
let waterfallInteractionBound = false;
let waterfallResizeObserver = null;
let waterfallMode = 'rf';
@@ -3296,7 +3273,7 @@ async function syncWaterfallToFrequency(freq, options = {}) {
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
@@ -3341,7 +3318,7 @@ async function zoomWaterfall(direction) {
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
@@ -3436,9 +3413,8 @@ function startAudioWaterfall() {
if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) {
lastAudioWaterfallDraw = ts;
visualizerAnalyser.getByteFrequencyData(dataArray);
const bins = Array.from(dataArray, v => v);
drawWaterfallRow(bins);
drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz');
drawWaterfallRow(dataArray);
drawSpectrumLine(dataArray, 0, maxFreqKhz, 'kHz');
}
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
};
@@ -3841,7 +3817,7 @@ async function startWaterfall(options = {}) {
span_mhz: spanMhz,
gain: gain,
device: device,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fftSize,
fps: 25,
avg_count: 4,
@@ -3943,11 +3919,31 @@ async function stopWaterfall() {
// WebSocket path
if (waterfallUseWebSocket && waterfallWebSocket) {
const ws = waterfallWebSocket;
try {
if (waterfallWebSocket.readyState === WebSocket.OPEN) {
waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' }));
if (ws.readyState === WebSocket.OPEN) {
// Wait for server to confirm stop (it terminates the IQ
// process and releases the USB device before responding).
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 4000);
const prevHandler = ws.onmessage;
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
try {
const msg = JSON.parse(event.data);
if (msg.status === 'stopped') {
clearTimeout(timeout);
resolve();
return;
}
} catch (_) {}
}
if (prevHandler) prevHandler(event);
};
ws.send(JSON.stringify({ cmd: 'stop' }));
});
}
waterfallWebSocket.close();
ws.close();
} catch (e) {
console.error('[WATERFALL] WebSocket stop error:', e);
}
@@ -3958,8 +3954,6 @@ async function stopWaterfall() {
if (typeof releaseDevice === 'function') {
releaseDevice('waterfall');
}
// Allow backend WebSocket handler to finish cleanup and release SDR
await new Promise(resolve => setTimeout(resolve, 300));
return;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82 -2
View File
@@ -223,6 +223,88 @@
</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' ? '&#9654;' : '&#9660;';">
<span>ANTENNA GUIDE</span>
<span class="panel-toggle" style="font-size: 10px; color: var(--text-muted);">&#9654;</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 &mdash; 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&ndash;7 dBi omnidirectional, ideal for 360&deg; coverage</li>
<li><strong style="color: var(--text-primary);">Range:</strong> 150&ndash;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&ndash;250 NM</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- Controls Bar - Reorganized -->
@@ -2455,7 +2537,6 @@ sudo make install</code>
// Check for remote dump1090 config (only for local mode)
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
if (remoteConfig === false) return;
// Check for agent SDR conflicts
if (useAgent && typeof checkAgentModeConflict === 'function') {
if (!checkAgentModeConflict('adsb')) {
@@ -2474,7 +2555,6 @@ sudo make install</code>
requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port;
}
try {
// Route through agent proxy if using remote agent
const url = useAgent
+874 -77
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -26,6 +26,75 @@
</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) &mdash; 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&ndash;50, designed for 156&ndash;163 MHz band</li>
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30&ndash;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()">
Start AIS Tracking
</button>
+55
View File
@@ -13,4 +13,59 @@
<span style="color: var(--accent-cyan);">Controls in function bar above map</span>
</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&ndash;148 MHz) &mdash; 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&ndash;25, good mobile/portable option</li>
<li><strong style="color: var(--text-primary);">2m/70cm dual-band:</strong> ~$20&ndash;40, also covers 70cm ham band</li>
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30&ndash;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>
+12 -14
View File
@@ -1,20 +1,18 @@
<!-- BLUETOOTH MODE -->
<div id="bluetoothMode" class="mode-content">
<!-- Capability Status -->
<div id="btCapabilityStatus" class="section" style="display: none;">
<!-- Populated by JavaScript with capability warnings -->
</div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" class="section" style="display: none; padding: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="section">
<h3>Scanner Configuration</h3>
<!-- Populated by JavaScript with capability warnings -->
<div id="btCapabilityStatus" style="display: none; margin-bottom: 8px;"></div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" style="display: none; margin-bottom: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="form-group">
<label>Adapter</label>
<select id="btAdapterSelect">
@@ -61,7 +59,7 @@
Stop Scanning
</button>
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">
+72
View File
@@ -0,0 +1,72 @@
<!-- BT LOCATE MODE -->
<div id="btLocateMode" class="mode-content">
<div class="section">
<h3>BT Locate</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
SAR Bluetooth device location &mdash; GPS-tagged signal trail mapping with proximity alerts for locating missing persons' devices.
</p>
</div>
<!-- Target Lock -->
<div class="section">
<h3>Target</h3>
<div id="btLocateHandoffCard" style="display: none; background: rgba(0,255,136,0.08); border: 1px solid rgba(0,255,136,0.3); border-radius: 6px; padding: 8px; margin-bottom: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--accent-green); text-transform: uppercase; font-weight: 600;">Handed off from BT</span>
<button onclick="BtLocate.clearHandoff()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 10px;">&times;</button>
</div>
<div id="btLocateHandoffName" style="font-size: 12px; font-weight: 600; color: var(--text-primary); margin-top: 4px;"></div>
<div id="btLocateHandoffMeta" style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);"></div>
</div>
<label class="input-label">MAC Address</label>
<input type="text" id="btLocateMac" class="text-input" placeholder="AA:BB:CC:DD:EE:FF" style="font-family: var(--font-mono); font-size: 11px;">
<label class="input-label" style="margin-top: 6px;">Name Pattern</label>
<input type="text" id="btLocateNamePattern" class="text-input" placeholder="iPhone, Galaxy, etc.">
<label class="input-label" style="margin-top: 6px;">IRK (hex, optional)</label>
<div style="display: flex; gap: 4px; align-items: center;">
<input type="text" id="btLocateIrk" class="text-input" placeholder="32 hex chars for RPA resolution" style="font-family: var(--font-mono); font-size: 10px; flex: 1;">
<button class="btl-detect-irk-btn" id="btLocateDetectIrkBtn" onclick="BtLocate.fetchPairedIrks()" title="Detect IRKs from paired devices">Detect</button>
</div>
<div id="btLocateIrkPicker" class="btl-irk-picker" style="display: none;">
<div id="btLocateIrkPickerStatus" class="btl-irk-picker-status"></div>
<div id="btLocateIrkPickerList" class="btl-irk-picker-list"></div>
</div>
</div>
<!-- Environment Preset -->
<div class="section">
<h3>Environment</h3>
<div class="btl-env-grid">
<button class="btl-env-btn" data-env="FREE_SPACE" onclick="BtLocate.setEnvironment('FREE_SPACE')">
<span class="btl-env-icon">&#127968;</span>
<span class="btl-env-label">Open Field</span>
<span class="btl-env-n">n=2.0</span>
</button>
<button class="btl-env-btn active" data-env="OUTDOOR" onclick="BtLocate.setEnvironment('OUTDOOR')">
<span class="btl-env-icon">&#127795;</span>
<span class="btl-env-label">Outdoor</span>
<span class="btl-env-n">n=2.2</span>
</button>
<button class="btl-env-btn" data-env="INDOOR" onclick="BtLocate.setEnvironment('INDOOR')">
<span class="btl-env-icon">&#127970;</span>
<span class="btl-env-label">Indoor</span>
<span class="btl-env-n">n=3.0</span>
</button>
</div>
</div>
<!-- Controls -->
<div class="section">
<div style="display: flex; gap: 6px;">
<button class="run-btn" id="btLocateStartBtn" onclick="BtLocate.start()">Start Locate</button>
<button class="stop-btn" id="btLocateStopBtn" onclick="BtLocate.stop()" style="display: none;">Stop</button>
</div>
<div id="btLocateScanStatus" style="display: none; margin-top: 6px; font-size: 10px; color: var(--text-dim);">
<span id="btLocateScanDot" style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; margin-right: 4px; vertical-align: middle;"></span>
<span id="btLocateScanText">BT scanner active</span>
</div>
</div>
</div>
+35 -8
View File
@@ -2,6 +2,9 @@
<div id="dmrMode" class="mode-content">
<div class="section">
<h3>Digital Voice</h3>
<div class="alpha-mode-notice">
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
</div>
<!-- Dependency Warning -->
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
@@ -19,13 +22,16 @@
<div class="form-group">
<label>Protocol</label>
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option>
<option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
For NXDN and ProVoice, use manual protocol selection for best lock reliability
</span>
</div>
<div class="form-group">
@@ -50,13 +56,25 @@
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none; margin-top: 12px;">
Stop Decoder
</button>
<!-- Bookmarks -->
<div class="section" style="margin-top: 8px;">
<h3>Bookmarks</h3>
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
<input type="number" id="dmrBookmarkFreq" placeholder="Freq MHz" step="0.0001"
style="flex: 1; font-size: 11px; padding: 4px 6px;">
<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 -->
<div class="section" style="margin-top: 12px;">
@@ -84,4 +102,13 @@
</div>
</div>
</div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
Stop Decoder
</button>
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
<!-- GPS MODE -->
<div id="gpsMode" class="mode-content">
<div class="section">
<h3>GPS Receiver</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Display live GPS data from gpsd &mdash; satellite sky view, signal strengths, position, velocity, DOP values, and timing.
</p>
</div>
<!-- Connection -->
<div class="section">
<h3>Connection</h3>
<div class="gps-connection-status">
<span class="gps-status-dot" id="gpsStatusDot"></span>
<span class="gps-status-text" id="gpsStatusText">Disconnected</span>
</div>
<div id="gpsDevicePath" style="font-size: 10px; color: var(--text-dim); margin-top: 4px; font-family: var(--font-mono);"></div>
<div style="display: flex; gap: 6px; margin-top: 8px;">
<button class="run-btn" id="gpsConnectBtn" onclick="GPS.connect()">Connect</button>
<button class="stop-btn" id="gpsDisconnectBtn" onclick="GPS.disconnect()" style="display: none;">Disconnect</button>
</div>
</div>
<!-- Fix Info -->
<div class="section">
<h3>Fix</h3>
<div class="gps-info-grid">
<div class="gps-info-item">
<span class="gps-info-label">Fix Type</span>
<span class="gps-info-value" id="gpsFixType">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">Satellites</span>
<span class="gps-info-value"><span id="gpsSatUsed">-</span> / <span id="gpsSatTotal">-</span></span>
</div>
</div>
</div>
<!-- Position -->
<div class="section">
<h3>Position</h3>
<div class="gps-info-grid">
<div class="gps-info-item">
<span class="gps-info-label">Latitude</span>
<span class="gps-info-value gps-mono" id="gpsLat">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">Longitude</span>
<span class="gps-info-value gps-mono" id="gpsLon">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">Altitude</span>
<span class="gps-info-value gps-mono" id="gpsAlt">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">Speed</span>
<span class="gps-info-value gps-mono" id="gpsSpeed">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">Heading</span>
<span class="gps-info-value gps-mono" id="gpsHeading">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">Climb</span>
<span class="gps-info-value gps-mono" id="gpsClimb">---</span>
</div>
</div>
</div>
<!-- DOP Values -->
<div class="section">
<h3>Dilution of Precision</h3>
<div class="gps-info-grid">
<div class="gps-info-item">
<span class="gps-info-label">HDOP</span>
<span class="gps-info-value gps-mono" id="gpsHdop">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">VDOP</span>
<span class="gps-info-value gps-mono" id="gpsVdop">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">PDOP</span>
<span class="gps-info-value gps-mono" id="gpsPdop">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">TDOP</span>
<span class="gps-info-value gps-mono" id="gpsTdop">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">GDOP</span>
<span class="gps-info-value gps-mono" id="gpsGdop">---</span>
</div>
</div>
</div>
<!-- Error Estimates -->
<div class="section">
<h3>Error Estimates</h3>
<div class="gps-info-grid">
<div class="gps-info-item">
<span class="gps-info-label">EPH (horiz)</span>
<span class="gps-info-value gps-mono" id="gpsEph">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">EPV (vert)</span>
<span class="gps-info-value gps-mono" id="gpsEpv">---</span>
</div>
<div class="gps-info-item">
<span class="gps-info-label">EPS (speed)</span>
<span class="gps-info-value gps-mono" id="gpsEps">---</span>
</div>
</div>
</div>
<!-- Timing -->
<div class="section">
<h3>GPS Time</h3>
<div class="gps-info-grid">
<div class="gps-info-item" style="grid-column: 1 / -1;">
<span class="gps-info-label">UTC</span>
<span class="gps-info-value gps-mono" id="gpsTime" style="font-size: 14px;">---</span>
</div>
</div>
</div>
</div>
+68
View File
@@ -55,6 +55,74 @@
</a>
</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 &mdash; 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 (&lt; 1 km) urban, indoor testing</li>
<li><strong style="color: var(--text-primary);">Upgrade:</strong> Replace with tuned antenna for 5&ndash;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&ndash;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&ndash;30, directional, great for point-to-point links</li>
<li><strong style="color: var(--text-primary);">Collinear:</strong> ~$20&ndash;40, omnidirectional with higher gain (~5&ndash;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 &mdash; 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 &lambda;/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 &lambda;/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&ndash;15 km</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
+56
View File
@@ -75,6 +75,62 @@
</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&ndash;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) &mdash; 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()">
Start Decoding
</button>
+53
View File
@@ -58,6 +58,59 @@
</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 &mdash; 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&ndash;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&deg; on SMA connector</li>
<li><strong style="color: var(--text-primary);">Yagi:</strong> Directional for targeting specific meters at distance (~$15&ndash;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 &lambda;/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()">
Start Listening
</button>
+55
View File
@@ -39,6 +39,61 @@
</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&deg;</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 &lambda;/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 &lambda;/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 &lambda;/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&ndash;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()">
Start Listening
</button>
+60
View File
@@ -39,4 +39,64 @@
Common modes: PD120, PD180, Martin1, Scottie1
</p>
</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) &mdash; 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 &mdash; ~$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&deg; 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&ndash;10 minutes</li>
<li><strong style="color: var(--text-primary);">Best passes:</strong> Elevation &gt; 30&deg; 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 &mdash; 2m filtered LNA at antenna feed</li>
<li><strong style="color: var(--text-primary);">Doppler:</strong> ISS moves fast &mdash; signal shifts &plusmn;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&ndash;10 min</td>
</tr>
</table>
</div>
</div>
</div>
</div>
+175
View File
@@ -0,0 +1,175 @@
<!-- SUBGHZ TRANSCEIVER MODE -->
<div id="subghzMode" class="mode-content">
<div class="section">
<h3>SubGHz Transceiver</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
HackRF One SubGHz transceiver. Capture raw signals, replay saved bursts,
and scan wideband activity with frequency analysis.
</p>
</div>
<!-- Device -->
<div class="section">
<h3>HackRF Device</h3>
<div class="subghz-device-status" id="subghzDeviceStatus">
<div class="subghz-device-row">
<span class="subghz-device-dot" id="subghzDeviceDot"></span>
<span class="subghz-device-label" id="subghzDeviceLabel">Checking...</span>
</div>
<div class="subghz-device-tools" id="subghzDeviceTools">
<span class="subghz-tool-badge" id="subghzToolHackrf" title="hackrf_transfer">HackRF</span>
<span class="subghz-tool-badge" id="subghzToolSweep" title="hackrf_sweep">Sweep</span>
</div>
</div>
<div class="form-group" style="margin-top: 8px;">
<label>Device Serial <span style="color: var(--text-dim); font-weight: normal;">(optional)</span></label>
<input type="text" id="subghzDeviceSerial" placeholder="auto-detect" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
</div>
</div>
<!-- Status -->
<div class="subghz-status-row" id="subghzStatusRow">
<div class="subghz-status-dot" id="subghzStatusDot"></div>
<span class="subghz-status-text" id="subghzStatusText">Idle</span>
<span class="subghz-status-timer" id="subghzStatusTimer"></span>
</div>
<!-- Frequency -->
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="subghzFrequency" value="433.92" step="0.001" min="1" max="6000">
</div>
<div class="subghz-preset-btns">
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(315)">315M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(433.92)">433.92M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(868)">868M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(915)">915M</button>
</div>
</div>
<!-- Gain -->
<div class="section">
<h3>Gain</h3>
<div class="form-group">
<label>LNA Gain (0-40 dB)</label>
<input type="range" id="subghzLnaGain" min="0" max="40" value="24" step="8" oninput="document.getElementById('subghzLnaVal').textContent=this.value">
<span id="subghzLnaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">24</span>
</div>
<div class="form-group">
<label>VGA Gain (0-62 dB)</label>
<input type="range" id="subghzVgaGain" min="0" max="62" value="20" step="2" oninput="document.getElementById('subghzVgaVal').textContent=this.value">
<span id="subghzVgaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="subghzSampleRate" class="mode-select">
<option value="2000000" selected>2 MHz</option>
<option value="4000000">4 MHz</option>
<option value="8000000">8 MHz</option>
<option value="10000000">10 MHz</option>
<option value="20000000">20 MHz</option>
</select>
</div>
</div>
<!-- Tabs: Receive RAW / Sweep -->
<div class="section">
<div class="subghz-tabs">
<button class="subghz-tab active" data-tab="rx" onclick="SubGhz.switchTab('rx')">Read RAW</button>
<button class="subghz-tab" data-tab="sweep" onclick="SubGhz.switchTab('sweep')">Sweep</button>
</div>
<!-- RX Tab -->
<div class="subghz-tab-content active" id="subghzTabRx">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Capture raw IQ data to file. Saved captures can be replayed or analyzed.
</p>
<div class="subghz-trigger-box">
<label class="subghz-trigger-toggle">
<input type="checkbox" id="subghzTriggerEnabled" onchange="SubGhz.syncTriggerControls()">
Smart Trigger Capture
</label>
<div class="subghz-trigger-grid">
<label>Pre-roll (ms)</label>
<input type="number" id="subghzTriggerPreMs" min="50" max="5000" step="50" value="350">
<label>Post-roll (ms)</label>
<input type="number" id="subghzTriggerPostMs" min="100" max="10000" step="50" value="700">
</div>
<p class="subghz-trigger-help">Auto-stops after burst + post-roll and trims capture window.</p>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzRxStartBtn" onclick="SubGhz.startRx()">Start Capture</button>
<button class="subghz-btn stop" id="subghzRxStopBtn" onclick="SubGhz.stopRx()" disabled>Stop Capture</button>
</div>
</div>
<!-- Sweep Tab -->
<div class="subghz-tab-content" id="subghzTabSweep">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Wideband spectrum analyzer using hackrf_sweep.
</p>
<div class="form-group">
<label>Frequency Range (MHz)</label>
<div class="subghz-sweep-range">
<input type="number" id="subghzSweepStart" value="300" min="1" max="6000" step="1">
<span>to</span>
<input type="number" id="subghzSweepEnd" value="928" min="1" max="6000" step="1">
</div>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzSweepStartBtn" onclick="SubGhz.startSweep()">Start Sweep</button>
<button class="subghz-btn stop" id="subghzSweepStopBtn" onclick="SubGhz.stopSweep()" disabled>Stop Sweep</button>
</div>
<div style="margin-top: 10px;">
<label style="font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px;">Detected Peaks</label>
<div class="subghz-peak-list" id="subghzPeakList"></div>
</div>
</div>
</div>
<!-- TX Settings (collapsible) -->
<div class="section">
<h3 style="cursor: pointer;" onclick="document.getElementById('subghzTxSection').classList.toggle('active')">
Transmit Settings <span style="font-size: 10px; color: var(--text-dim);">&#9660;</span>
</h3>
<div id="subghzTxSection" style="display: none;">
<div class="subghz-tx-warning">
WARNING: Transmitting radio signals may be illegal without proper authorization.
Only transmit on frequencies you are licensed for and within ISM band limits.
TX is restricted to ISM bands: 300-348, 387-464, 779-928 MHz.
</div>
<div class="form-group">
<label>TX VGA Gain (0-47 dB)</label>
<input type="range" id="subghzTxGain" min="0" max="47" value="20" step="1" oninput="document.getElementById('subghzTxGainVal').textContent=this.value">
<span id="subghzTxGainVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Max Duration (seconds)</label>
<input type="number" id="subghzTxMaxDuration" value="10" min="1" max="30" step="1">
</div>
</div>
</div>
<!-- Saved Signals Library -->
<div class="section">
<h3>Saved Signals</h3>
<div class="subghz-captures-list" id="subghzSidebarCaptures" style="max-height: 220px; overflow-y: auto;">
<div class="subghz-empty" id="subghzSidebarCapturesEmpty">No saved captures yet</div>
</div>
</div>
</div>
<script>
// Toggle TX section visibility
document.addEventListener('DOMContentLoaded', function() {
const h3 = document.querySelector('#subghzTxSection')?.previousElementSibling;
if (h3) {
h3.addEventListener('click', function() {
const section = document.getElementById('subghzTxSection');
if (section) section.style.display = section.style.display === 'none' ? 'block' : 'none';
});
}
});
</script>
+14 -13
View File
@@ -2,7 +2,7 @@
<div id="tscmMode" class="mode-content">
<!-- Configuration -->
<div class="section">
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<h3>TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<div class="form-group">
<label>Sweep Type</label>
@@ -65,14 +65,6 @@
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()" style="margin-top: 12px;">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none; margin-top: 12px;">
Stop Sweep
</button>
<!-- Futuristic Scanner Progress -->
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
<div class="scanner-ring">
@@ -115,8 +107,8 @@
</div>
<!-- Advanced -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 12px;">Advanced</h3>
<div class="section">
<h3>Advanced</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Baseline Recording</label>
@@ -156,8 +148,8 @@
</div>
<!-- Tools -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 10px;">Tools</h3>
<div class="section">
<h3>Tools</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities
@@ -182,4 +174,13 @@
<!-- Device Warnings -->
<div id="tscmDeviceWarnings" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255,153,51,0.1); border: 1px solid rgba(255,153,51,0.3); border-radius: 4px;"></div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none;">
Stop Sweep
</button>
</div>
</div>
@@ -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 &mdash; your stock SDR antenna will NOT work.
</p>
<p style="margin-bottom: 10px;">
Weather satellites transmit at 137.1&ndash;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 &mdash; ~$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&deg; between elements (not 180&deg;)</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&deg; toward expected pass direction</li>
<li><strong style="color: var(--text-primary);">Polarization:</strong> The 120&deg; 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
&lt;---------&gt;
====+==== dipole 1
|
====+==== dipole 2
&lt;---------&gt;
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&deg; between the two dipole pairs</li>
<li><strong style="color: var(--text-primary);">Phasing:</strong> Feed dipole 2 with a 90&deg; 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) &mdash; 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 &mdash; Quadrifilar Helix (Best &mdash; ~$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&deg;</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 &mdash; 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 &mdash; 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 &mdash; 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 (&lt;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;">&gt; 30&deg;</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;">&#9660;</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>
+26 -23
View File
@@ -1,7 +1,8 @@
<!-- WiFi MODE -->
<div id="wifiMode" class="mode-content">
<!-- Scan Mode Tabs -->
<div class="section" style="padding: 8px;">
<div class="section">
<h3>Signal Source</h3>
<div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;">
<button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;">
Quick Scan
@@ -168,29 +169,8 @@
</div>
</div>
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
<!-- Export Section -->
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
@@ -201,4 +181,27 @@
</button>
</div>
</div>
<div class="mode-actions-bottom">
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
</div>
</div>
+8 -2
View File
@@ -71,8 +71,8 @@
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mode_item('dmr', 'Digital Voice', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</div>
</div>
@@ -87,6 +87,7 @@
<div class="mode-nav-dropdown-menu">
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }}
</div>
</div>
@@ -118,7 +119,9 @@
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
{% 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('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
</div>
</div>
@@ -178,6 +181,7 @@
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{% if is_index_page %}
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
@@ -185,12 +189,14 @@
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }}
{% 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('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mobile_item('dmr', 'DMR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}
-1
View File
@@ -323,7 +323,6 @@
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>
<option value="dmr">DMR</option>
<option value="tscm">TSCM</option>
<option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option>
+34 -11
View File
@@ -107,12 +107,8 @@
<label>TARGET:</label>
<select id="satSelect" onchange="onSatelliteChange()">
<option value="25544">ISS (ZARYA)</option>
<option value="25338">NOAA 15</option>
<option value="28654">NOAA 18</option>
<option value="33591">NOAA 19</option>
<option value="40069">METEOR-M2</option>
<option value="43013">NOAA 20</option>
<option value="54234">METEOR-M2-3</option>
<option value="57166">METEOR-M2-3</option>
</select>
</div>
@@ -273,16 +269,42 @@
let currentLocationSource = 'local';
let agents = [];
const satellites = {
let satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
25338: { name: 'NOAA 15', color: '#00ff00' },
28654: { name: 'NOAA 18', color: '#ff6600' },
33591: { name: 'NOAA 19', color: '#ff3366' },
40069: { name: 'METEOR-M2', color: '#9370DB' },
43013: { name: 'NOAA 20', color: '#00ffaa' },
54234: { name: 'METEOR-M2-3', color: '#ff00ff' }
57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
};
const satColors = ['#00ffff', '#9370DB', '#ff00ff', '#00ff00', '#ff6600', '#ffff00', '#ff69b4', '#7b68ee'];
function loadDashboardSatellites() {
fetch('/satellite/tracked?enabled=true')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites && data.satellites.length > 0) {
const newSats = {};
const select = document.getElementById('satSelect');
select.innerHTML = '';
data.satellites.forEach((sat, i) => {
const norad = parseInt(sat.norad_id);
newSats[norad] = {
name: sat.name,
color: satellites[norad]?.color || satColors[i % satColors.length]
};
const opt = document.createElement('option');
opt.value = norad;
opt.textContent = sat.name;
select.appendChild(opt);
});
satellites = newSats;
// Default to ISS if available
if (newSats[25544]) select.value = '25544';
selectedSatellite = parseInt(select.value);
}
})
.catch(() => {});
}
function onSatelliteChange() {
const select = document.getElementById('satSelect');
selectedSatellite = parseInt(select.value);
@@ -339,6 +361,7 @@
}
document.addEventListener('DOMContentLoaded', () => {
loadDashboardSatellites();
setupEmbeddedMode();
const usedShared = applySharedObserverLocation();
initGroundMap();
+278
View File
@@ -0,0 +1,278 @@
"""Tests for BT Locate — Bluetooth SAR Device Location System."""
from unittest.mock import MagicMock, patch
import pytest
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
from cryptography.hazmat.primitives.ciphers import modes as cipher_modes
HAS_CRYPTOGRAPHY = True
except ImportError:
HAS_CRYPTOGRAPHY = False
from utils.bt_locate import (
DistanceEstimator,
Environment,
LocateSession,
LocateTarget,
get_locate_session,
resolve_rpa,
start_locate_session,
stop_locate_session,
)
class TestResolveRPA:
"""Test BLE Resolvable Private Address resolution."""
@pytest.mark.skipif(not HAS_CRYPTOGRAPHY, reason="cryptography not installed")
def test_resolve_rpa_valid_match(self):
"""Test RPA resolution with known IRK/address pair.
Uses test vector: IRK = all zeros, we generate matching address.
"""
# The ah() function: encrypt(IRK, 0x00..00 || prand) then take last 3 bytes
irk = b'\x00' * 16
# Choose prand with upper 2 bits = 01 (resolvable)
prand = bytes([0x40, 0x00, 0x01])
plaintext = b'\x00' * 13 + prand
c = Cipher(algorithms.AES(irk), cipher_modes.ECB())
enc = c.encryptor()
encrypted = enc.update(plaintext) + enc.finalize()
hash_bytes = encrypted[13:16]
# Build address: prand || hash
addr_bytes = prand + hash_bytes
address = ':'.join(f'{b:02X}' for b in addr_bytes)
assert resolve_rpa(irk, address) is True
def test_resolve_rpa_invalid_address(self):
"""Test RPA resolution with non-matching address."""
irk = b'\x00' * 16
# Non-resolvable address (upper 2 bits != 01)
assert resolve_rpa(irk, 'FF:FF:FF:FF:FF:FF') is False
@pytest.mark.skipif(not HAS_CRYPTOGRAPHY, reason="cryptography not installed")
def test_resolve_rpa_wrong_irk(self):
"""Test RPA resolution with wrong IRK."""
irk = b'\x00' * 16
prand = bytes([0x40, 0x00, 0x01])
plaintext = b'\x00' * 13 + prand
c = Cipher(algorithms.AES(irk), cipher_modes.ECB())
enc = c.encryptor()
encrypted = enc.update(plaintext) + enc.finalize()
hash_bytes = encrypted[13:16]
addr_bytes = prand + hash_bytes
address = ':'.join(f'{b:02X}' for b in addr_bytes)
# Different IRK should fail
wrong_irk = b'\x01' * 16
assert resolve_rpa(wrong_irk, address) is False
def test_resolve_rpa_short_address(self):
"""Test with invalid short address."""
irk = b'\x00' * 16
assert resolve_rpa(irk, 'AA:BB') is False
def test_resolve_rpa_empty(self):
"""Test with empty inputs."""
assert resolve_rpa(b'\x00' * 16, '') is False
class TestDistanceEstimator:
"""Test RSSI-to-distance estimation."""
def test_free_space_distance(self):
estimator = DistanceEstimator(path_loss_exponent=2.0, rssi_at_1m=-59)
# At 1m, RSSI should be -59, so distance should be ~1m
d = estimator.estimate(-59)
assert abs(d - 1.0) < 0.01
def test_weaker_signal_farther(self):
estimator = DistanceEstimator(path_loss_exponent=2.0)
d1 = estimator.estimate(-50)
d2 = estimator.estimate(-70)
assert d2 > d1
def test_indoor_closer_estimate(self):
"""Indoor (n=3) should estimate closer distance for same RSSI."""
free_space = DistanceEstimator(path_loss_exponent=2.0)
indoor = DistanceEstimator(path_loss_exponent=3.0)
rssi = -75
d_free = free_space.estimate(rssi)
d_indoor = indoor.estimate(rssi)
# With higher path loss exponent, same RSSI means closer distance
assert d_indoor < d_free
def test_proximity_band_immediate(self):
assert DistanceEstimator.proximity_band(0.5) == 'IMMEDIATE'
def test_proximity_band_near(self):
assert DistanceEstimator.proximity_band(3.0) == 'NEAR'
def test_proximity_band_far(self):
assert DistanceEstimator.proximity_band(10.0) == 'FAR'
class TestLocateTarget:
"""Test target matching."""
def test_match_by_mac(self):
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
device = MagicMock()
device.device_id = 'other'
device.address = 'AA:BB:CC:DD:EE:FF'
device.name = None
assert target.matches(device) is True
def test_match_by_mac_case_insensitive(self):
target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff')
device = MagicMock()
device.device_id = 'other'
device.address = 'AA:BB:CC:DD:EE:FF'
device.name = None
assert target.matches(device) is True
def test_match_by_name_pattern(self):
target = LocateTarget(name_pattern='iPhone')
device = MagicMock()
device.device_id = 'other'
device.address = '00:00:00:00:00:00'
device.name = "John's iPhone 15"
assert target.matches(device) is True
def test_no_match(self):
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
device = MagicMock()
device.device_id = 'other'
device.address = '11:22:33:44:55:66'
device.name = None
assert target.matches(device) is False
def test_match_by_device_id(self):
target = LocateTarget(device_id='my-device-123')
device = MagicMock()
device.device_id = 'my-device-123'
device.address = '00:00:00:00:00:00'
device.name = None
assert target.matches(device) is True
def test_to_dict(self):
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF', known_name='Test')
d = target.to_dict()
assert d['mac_address'] == 'AA:BB:CC:DD:EE:FF'
assert d['known_name'] == 'Test'
class TestLocateSession:
"""Test locate session lifecycle."""
@patch('utils.bt_locate.get_bluetooth_scanner')
def test_start_stop(self, mock_get_scanner):
mock_scanner = MagicMock()
mock_get_scanner.return_value = mock_scanner
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
session = LocateSession(target, Environment.OUTDOOR)
session.start()
assert session.active is True
mock_scanner.add_device_callback.assert_called_once()
session.stop()
assert session.active is False
mock_scanner.remove_device_callback.assert_called_once()
@patch('utils.bt_locate.get_bluetooth_scanner')
@patch('utils.bt_locate.get_current_position')
def test_detection_creates_trail_point(self, mock_gps, mock_get_scanner):
mock_scanner = MagicMock()
mock_get_scanner.return_value = mock_scanner
mock_gps.return_value = None # No GPS
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
session = LocateSession(target, Environment.OUTDOOR)
session.start()
# Simulate device callback
device = MagicMock()
device.device_id = 'test'
device.address = 'AA:BB:CC:DD:EE:FF'
device.name = 'Test Device'
device.rssi_current = -65
session._on_device(device)
assert session.detection_count == 1
assert len(session.trail) == 1
assert session.trail[0].rssi == -65
@patch('utils.bt_locate.get_bluetooth_scanner')
def test_non_matching_device_ignored(self, mock_get_scanner):
mock_scanner = MagicMock()
mock_get_scanner.return_value = mock_scanner
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
session = LocateSession(target, Environment.OUTDOOR)
session.start()
device = MagicMock()
device.device_id = 'other'
device.address = '11:22:33:44:55:66'
device.name = None
device.rssi_current = -70
session._on_device(device)
assert session.detection_count == 0
@patch('utils.bt_locate.get_bluetooth_scanner')
def test_get_status(self, mock_get_scanner):
mock_scanner = MagicMock()
mock_get_scanner.return_value = mock_scanner
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
session = LocateSession(target, Environment.FREE_SPACE)
session.start()
status = session.get_status()
assert status['active'] is True
assert status['environment'] == 'FREE_SPACE'
assert status['detection_count'] == 0
class TestModuleLevelSessionManagement:
"""Test module-level session functions."""
@patch('utils.bt_locate.get_bluetooth_scanner')
def test_start_and_get_session(self, mock_get_scanner):
mock_scanner = MagicMock()
mock_get_scanner.return_value = mock_scanner
target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
session = start_locate_session(target)
assert get_locate_session() is session
assert session.active is True
stop_locate_session()
assert get_locate_session() is None
@patch('utils.bt_locate.get_bluetooth_scanner')
def test_start_replaces_existing_session(self, mock_get_scanner):
mock_scanner = MagicMock()
mock_get_scanner.return_value = mock_scanner
target1 = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
session1 = start_locate_session(target1)
target2 = LocateTarget(mac_address='11:22:33:44:55:66')
session2 = start_locate_session(target2)
assert get_locate_session() is session2
assert session1.active is False
assert session2.active is True
stop_locate_session()
+107 -6
View File
@@ -1,7 +1,9 @@
"""Tests for the DMR / Digital Voice decoding module."""
import queue
from unittest.mock import patch, MagicMock
import pytest
import routes.dmr as dmr_module
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
@@ -66,6 +68,16 @@ def test_parse_talkgroup_dsd_fme_format():
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')
@@ -98,6 +110,23 @@ def test_parse_unrecognized():
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
@@ -105,11 +134,11 @@ def test_dsd_fme_flags_differ_from_classic():
def test_dsd_fme_protocol_flags_known_values():
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fd']
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme)
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'] == [] # No dedicated flag
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
@@ -126,9 +155,9 @@ def test_dsd_protocol_flags_known_values():
def test_dsd_fme_modulation_hints():
"""C4FM modulation hints should be set for C4FM protocols."""
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
assert _DSD_FME_MODULATION['p25'] == ['-mc']
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
# D-Star and ProVoice should not have forced modulation
# P25, D-Star and ProVoice should not have forced modulation
assert 'p25' not in _DSD_FME_MODULATION
assert 'dstar' not in _DSD_FME_MODULATION
assert 'provoice' not in _DSD_FME_MODULATION
@@ -145,6 +174,40 @@ def auth_client(client):
return client
@pytest.fixture(autouse=True)
def reset_dmr_globals():
"""Reset DMR globals before/after each test to avoid cross-test bleed."""
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
yield
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
def test_dmr_tools(auth_client):
"""Tools endpoint should return availability info."""
resp = auth_client.get('/dmr/tools')
@@ -208,3 +271,41 @@ def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream')
def test_dmr_start_exception_cleans_up_resources(auth_client):
"""If startup fails after rtl_fm launch, process/device state should be reset."""
rtl_proc = MagicMock()
rtl_proc.poll.return_value = None
rtl_proc.wait.return_value = 0
rtl_proc.stdout = MagicMock()
rtl_proc.stderr = MagicMock()
builder = MagicMock()
builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M']
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \
patch('routes.dmr.find_ffmpeg', return_value=None), \
patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \
patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \
patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \
patch('routes.dmr.app_module.release_sdr_device') as release_mock, \
patch('routes.dmr.register_process') as register_mock, \
patch('routes.dmr.unregister_process') as unregister_mock, \
patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
'device': 0,
})
assert resp.status_code == 500
assert 'dsd launch failed' in resp.get_json()['message']
register_mock.assert_called_once_with(rtl_proc)
rtl_proc.terminate.assert_called_once()
unregister_mock.assert_called_once_with(rtl_proc)
release_mock.assert_called_once_with(0)
assert dmr_module.dmr_running is False
assert dmr_module.dmr_rtl_process is None
assert dmr_module.dmr_dsd_process is None
+8 -5
View File
@@ -313,8 +313,9 @@ class TestDSCDecoder:
def test_decode_mmsi_with_leading_zeros(self, decoder):
"""Test MMSI decoding handles leading zeros."""
# Coast station: 002320001
# 00-23-20-00-01 -> [0, 23, 20, 0, 1]
symbols = [0, 23, 20, 0, 1]
# Padded to 10 digits: 0002320001
# BCD pairs: 00-02-32-00-01 -> [0, 2, 32, 0, 1]
symbols = [0, 2, 32, 0, 1]
result = decoder._decode_mmsi(symbols)
assert result == '002320001'
@@ -328,14 +329,16 @@ class TestDSCDecoder:
# Symbols > 99 should be treated as 0
symbols = [100, 32, 12, 34, 56]
result = decoder._decode_mmsi(symbols)
# First symbol becomes 00
assert result == '003212345'[-9:]
# First symbol (100) becomes 00, padded result "0032123456",
# trim leading pad digit -> "032123456"
assert result == '032123456'
def test_decode_position_northeast(self, decoder):
"""Test position decoding for NE quadrant."""
# Quadrant 10 = NE (lat+, lon+)
# Position: 51°30'N, 0°10'E
symbols = [10, 51, 30, 0, 10, 0, 0, 0, 0, 0]
# lon_deg = symbols[3]*100 + symbols[4] = 0, lon_min = symbols[5] = 10
symbols = [10, 51, 30, 0, 0, 10, 0, 0, 0, 0]
result = decoder._decode_position(symbols)
assert result is not None
+20 -10
View File
@@ -46,20 +46,30 @@ class TestHealthEndpoint:
assert 'processes' in data
assert 'data' in data
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
processes = data['processes']
assert 'pager' in processes
assert 'sensor' in processes
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
class TestDevicesEndpoint:
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
def test_health_reports_dmr_route_process(self, client):
"""Health should reflect DMR route module state (not stale app globals)."""
mock_proc = MagicMock()
mock_proc.poll.return_value = None
with patch('routes.dmr.dmr_running', True), \
patch('routes.dmr.dmr_dsd_process', mock_proc):
response = client.get('/health')
data = json.loads(response.data)
assert data['processes']['dmr'] is True
class TestDevicesEndpoint:
"""Tests for devices endpoint."""
def test_get_devices(self, client):
+38
View File
@@ -0,0 +1,38 @@
"""Tests for rtl_fm modulation token mapping."""
from routes.listening_post import _rtl_fm_demod_mode as listening_post_rtl_mode
from utils.sdr.base import SDRDevice, SDRType
from utils.sdr.rtlsdr import RTLSDRCommandBuilder, _rtl_fm_demod_mode as builder_rtl_mode
def _dummy_rtlsdr_device() -> SDRDevice:
return SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=0,
name='RTL-SDR',
serial='00000001',
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES,
)
def test_rtl_fm_modulation_maps_wfm_to_wbfm() -> None:
assert listening_post_rtl_mode('wfm') == 'wbfm'
assert builder_rtl_mode('wfm') == 'wbfm'
def test_rtl_fm_modulation_keeps_other_modes() -> None:
assert listening_post_rtl_mode('fm') == 'fm'
assert builder_rtl_mode('am') == 'am'
def test_rtlsdr_builder_uses_wbfm_token_for_wfm() -> None:
builder = RTLSDRCommandBuilder()
cmd = builder.build_fm_demod_command(
device=_dummy_rtlsdr_device(),
frequency_mhz=98.1,
modulation='wfm',
)
mode_index = cmd.index('-M')
assert cmd[mode_index + 1] == 'wbfm'
+608
View File
@@ -0,0 +1,608 @@
"""Tests for SubGhzManager utility module."""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzManager, SubGhzCapture
@pytest.fixture
def tmp_data_dir(tmp_path):
"""Create a temporary data directory for SubGhz captures."""
data_dir = tmp_path / 'subghz'
data_dir.mkdir()
(data_dir / 'captures').mkdir()
return data_dir
@pytest.fixture
def manager(tmp_data_dir):
"""Create a SubGhzManager with temp directory."""
return SubGhzManager(data_dir=tmp_data_dir)
class TestSubGhzManagerInit:
def test_creates_data_dirs(self, tmp_path):
data_dir = tmp_path / 'new_subghz'
mgr = SubGhzManager(data_dir=data_dir)
assert (data_dir / 'captures').is_dir()
def test_active_mode_idle(self, manager):
assert manager.active_mode == 'idle'
def test_get_status_idle(self, manager):
status = manager.get_status()
assert status['mode'] == 'idle'
class TestToolDetection:
def test_check_hackrf_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'):
assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/rtl_433'):
assert manager.check_rtl433() is True
def test_check_sweep_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'):
assert manager.check_sweep() is True
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_start_receive_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b'')
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
result = manager.start_receive(
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'rx'
def test_start_receive_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_stop_receive_not_running(self, manager):
result = manager.stop_receive()
assert result['status'] == 'not_running'
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / 'captures' / 'test.iq'
iq_file.write_bytes(b'\x00' * 1024)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
manager._rx_file = iq_file
manager._rx_frequency_hz = 433920000
manager._rx_sample_rate = 2000000
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
with patch('utils.subghz.safe_terminate'), \
patch('time.time', return_value=1005.0):
result = manager.stop_receive()
assert result['status'] == 'stopped'
assert 'capture' in result
assert result['capture']['frequency_hz'] == 433920000
# Verify JSON sidecar was written
meta_path = iq_file.with_suffix('.json')
assert meta_path.exists()
meta = json.loads(meta_path.read_text())
assert meta['frequency_hz'] == 433920000
assert isinstance(meta.get('bursts'), list)
assert meta['bursts'][0]['peak_level'] == 42
class TestTxSafety:
def test_validate_tx_frequency_ism_433(self):
result = SubGhzManager.validate_tx_frequency(433920000)
assert result is None # Valid
def test_validate_tx_frequency_ism_315(self):
result = SubGhzManager.validate_tx_frequency(315000000)
assert result is None
def test_validate_tx_frequency_ism_915(self):
result = SubGhzManager.validate_tx_frequency(915000000)
assert result is None
def test_validate_tx_frequency_out_of_band(self):
result = SubGhzManager.validate_tx_frequency(100000000) # 100 MHz
assert result is not None
assert 'outside allowed TX bands' in result
def test_validate_tx_frequency_between_bands(self):
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None
def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='nonexistent')
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
# Create a capture with out-of-band frequency
meta = {
'id': 'test123',
'filename': 'test.iq',
'frequency_hz': 100000000, # 100 MHz - out of ISM
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
}
meta_path = tmp_data_dir / 'captures' / 'test.json'
meta_path.write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'outside allowed TX bands' in result['message']
def test_transmit_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
'id': 'seg001',
'filename': 'seg.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 1.0,
'size_bytes': 2000,
}
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'), \
patch('threading.Timer', return_value=mock_timer), \
patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id='seg001',
start_seconds=0.2,
duration_seconds=0.3,
)
assert result['status'] == 'transmitting'
assert result['segment'] is not None
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
class TestCaptureLibrary:
def test_list_captures_empty(self, manager):
captures = manager.list_captures()
assert captures == []
def test_list_captures_with_data(self, manager, tmp_data_dir):
meta = {
'id': 'cap001',
'filename': 'test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 5.0,
'size_bytes': 1024,
'label': 'test capture',
}
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
captures = manager.list_captures()
assert len(captures) == 1
assert captures[0].capture_id == 'cap001'
assert captures[0].label == 'test capture'
def test_get_capture(self, manager, tmp_data_dir):
meta = {
'id': 'cap002',
'filename': 'test2.iq',
'frequency_hz': 315000000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}
(tmp_data_dir / 'captures' / 'test2.json').write_text(json.dumps(meta))
cap = manager.get_capture('cap002')
assert cap is not None
assert cap.frequency_hz == 315000000
def test_get_capture_not_found(self, manager):
cap = manager.get_capture('nonexistent')
assert cap is None
def test_delete_capture(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'delete_me.iq'
meta_path = captures_dir / 'delete_me.json'
iq_path.write_bytes(b'\x00' * 100)
meta_path.write_text(json.dumps({
'id': 'del001',
'filename': 'delete_me.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
assert manager.delete_capture('del001') is True
assert not iq_path.exists()
assert not meta_path.exists()
def test_delete_capture_not_found(self, manager):
assert manager.delete_capture('nonexistent') is False
def test_update_label(self, manager, tmp_data_dir):
meta = {
'id': 'lbl001',
'filename': 'label_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'label': '',
}
meta_path = tmp_data_dir / 'captures' / 'label_test.json'
meta_path.write_text(json.dumps(meta))
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
updated = json.loads(meta_path.read_text())
assert updated['label'] == 'Garage Remote'
assert updated['label_source'] == 'manual'
def test_update_label_not_found(self, manager):
assert manager.update_capture_label('nonexistent', 'test') is False
def test_get_capture_path(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'path_test.iq'
iq_path.write_bytes(b'\x00' * 100)
(captures_dir / 'path_test.json').write_text(json.dumps({
'id': 'pth001',
'filename': 'path_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
path = manager.get_capture_path('pth001')
assert path is not None
assert path.name == 'path_test.iq'
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path('nonexistent') is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'trim_src.iq'
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / 'trim_src.json').write_text(json.dumps({
'id': 'trim001',
'filename': 'trim_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'label': 'Weather Burst',
'bursts': [
{
'start_seconds': 0.55,
'duration_seconds': 0.2,
'peak_level': 67,
'fingerprint': 'abc123',
'modulation_hint': 'OOK/ASK',
'modulation_confidence': 0.9,
}
],
}))
result = manager.trim_capture(
capture_id='trim001',
start_seconds=0.5,
duration_seconds=0.4,
)
assert result['status'] == 'ok'
assert result['capture']['id'] != 'trim001'
assert result['capture']['size_bytes'] == 800
assert result['capture']['label'].endswith('(Trim)')
trimmed_iq = captures_dir / result['capture']['filename']
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix('.json')
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'auto_src.iq'
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / 'auto_src.json').write_text(json.dumps({
'id': 'trim002',
'filename': 'auto_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'bursts': [
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
],
}))
result = manager.trim_capture(capture_id='trim002')
assert result['status'] == 'ok'
assert result['segment']['auto_selected'] is True
assert result['capture']['duration_seconds'] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
'id': 'grp001',
'filename': 'a.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
cap_b = {
'id': 'grp002',
'filename': 'b.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:01:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result['status'] == 'error'
def test_start_sweep_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stdout = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'):
manager._sweep_available = None
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
assert result['status'] == 'started'
# Signal daemon threads to stop so they don't outlive the test
manager._sweep_running = False
def test_stop_sweep_not_running(self, manager):
result = manager.stop_sweep()
assert result['status'] == 'not_running'
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name):
if name == 'hackrf_transfer':
return '/usr/bin/hackrf_transfer'
return None
with patch('shutil.which', side_effect=which_side_effect):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'rtl_433' in result['message']
def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock()
mock_hackrf_proc.poll.return_value = None
mock_hackrf_proc.stdout = MagicMock()
mock_hackrf_proc.stderr = MagicMock()
mock_hackrf_proc.stderr.readline = MagicMock(return_value=b'')
mock_rtl433_proc = MagicMock()
mock_rtl433_proc.poll.return_value = None
mock_rtl433_proc.stdout = MagicMock()
mock_rtl433_proc.stderr = MagicMock()
mock_rtl433_proc.stderr.readline = MagicMock(return_value=b'')
call_count = [0]
def popen_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return mock_hackrf_proc
return mock_rtl433_proc
with patch('shutil.which', return_value='/usr/bin/tool'), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(
frequency_hz=433920000,
sample_rate=2000000,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'decode'
# Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert hackrf_cmd[0] == 'hackrf_transfer'
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc
assert manager._decode_process is mock_rtl433_proc
# Signal daemon threads to stop so they don't outlive the test
manager._decode_stop = True
def test_stop_decode_not_running(self, manager):
result = manager.stop_decode()
assert result['status'] == 'not_running'
def test_stop_decode_terminates_both(self, manager):
mock_hackrf = MagicMock()
mock_hackrf.poll.return_value = None
mock_rtl433 = MagicMock()
mock_rtl433.poll.return_value = None
manager._decode_hackrf_process = mock_hackrf
manager._decode_process = mock_rtl433
manager._decode_frequency_hz = 433920000
with patch('utils.subghz.safe_terminate') as mock_term, \
patch('utils.subghz.unregister_process'):
result = manager.stop_decode()
assert result['status'] == 'stopped'
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert mock_term.call_count == 2
class TestStopAll:
def test_stop_all_clears_processes(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
with patch('utils.subghz.safe_terminate'):
manager.stop_all()
assert manager._rx_process is None
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert manager._tx_process is None
assert manager._sweep_process is None
class TestSubGhzCapture:
def test_to_dict(self):
cap = SubGhzCapture(
capture_id='abc123',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
duration_seconds=5.0,
size_bytes=1024,
label='Test',
)
d = cap.to_dict()
assert d['id'] == 'abc123'
assert d['frequency_hz'] == 433920000
assert d['label'] == 'Test'
+433
View File
@@ -0,0 +1,433 @@
"""Tests for SubGHz transceiver routes."""
from __future__ import annotations
import json
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzCapture
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestSubGhzRoutes:
"""Tests for /subghz/ endpoints."""
def test_get_status(self, client, auth_client):
"""GET /subghz/status returns manager status."""
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_status.return_value = {
'mode': 'idle',
'hackrf_available': True,
'rtl433_available': True,
'sweep_available': True,
}
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/status')
assert response.status_code == 200
data = response.get_json()
assert data['mode'] == 'idle'
assert data['hackrf_available'] is True
def test_get_presets(self, client, auth_client):
"""GET /subghz/presets returns frequency presets."""
response = auth_client.get('/subghz/presets')
assert response.status_code == 200
data = response.get_json()
assert 'presets' in data
assert '433.92 MHz' in data['presets']
assert 'sample_rates' in data
# ------ RECEIVE ------
def test_start_receive_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {
'status': 'started',
'frequency_hz': 433920000,
'sample_rate': 2000000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_receive_missing_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={})
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_receive_invalid_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 'not_a_number',
})
assert response.status_code == 400
def test_stop_receive(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_receive.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/stop')
assert response.status_code == 200
def test_start_receive_trigger_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {'status': 'started'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'trigger_enabled': True,
'trigger_pre_ms': 400,
'trigger_post_ms': 900,
})
assert response.status_code == 200
kwargs = mock_mgr.start_receive.call_args.kwargs
assert kwargs['trigger_enabled'] is True
assert kwargs['trigger_pre_ms'] == 400
assert kwargs['trigger_post_ms'] == 900
# ------ DECODE ------
def test_start_decode_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
mock_mgr.start_decode.assert_called_once()
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'weather'
def test_start_decode_profile_all(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
'decode_profile': 'all',
})
assert response.status_code == 200
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'all'
def test_start_decode_missing_freq(self, client, auth_client):
response = auth_client.post('/subghz/decode/start', json={})
assert response.status_code == 400
def test_stop_decode(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_decode.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/stop')
assert response.status_code == 200
# ------ TRANSMIT ------
def test_transmit_missing_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={})
assert response.status_code == 400
data = response.get_json()
assert 'capture_id is required' in data['message']
def test_transmit_invalid_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': '../../../etc/passwd',
})
assert response.status_code == 400
data = response.get_json()
assert 'Invalid' in data['message']
def test_transmit_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'transmitting'
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] is None
assert kwargs['duration_seconds'] is None
def test_transmit_segment_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
'segment': {'start_seconds': 0.1, 'duration_seconds': 0.4},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
'start_seconds': 0.1,
'duration_seconds': 0.4,
})
assert response.status_code == 200
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.4
def test_transmit_invalid_segment_param(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'start_seconds': 'not-a-number',
})
assert response.status_code == 400
def test_stop_transmit(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_transmit.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit/stop')
assert response.status_code == 200
# ------ SWEEP ------
def test_start_sweep_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_sweep.return_value = {
'status': 'started',
'freq_start_mhz': 300,
'freq_end_mhz': 928,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 300,
'freq_end_mhz': 928,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_sweep_invalid_range(self, client, auth_client):
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 928,
'freq_end_mhz': 300, # start > end
})
assert response.status_code == 400
def test_stop_sweep(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_sweep.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/stop')
assert response.status_code == 200
# ------ CAPTURES ------
def test_list_captures_empty(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.list_captures.return_value = []
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 0
assert data['captures'] == []
def test_list_captures_with_data(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap1',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.list_captures.return_value = [cap]
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 1
assert data['captures'][0]['id'] == 'cap1'
def test_get_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap2',
filename='test2.iq',
frequency_hz=315000000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.get_capture.return_value = cap
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/cap2')
assert response.status_code == 200
data = response.get_json()
assert data['capture']['frequency_hz'] == 315000000
def test_get_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_capture.return_value = None
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_get_capture_invalid_id(self, client, auth_client):
response = auth_client.get('/subghz/captures/bad-id!')
assert response.status_code == 400
def test_delete_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/cap1')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'deleted'
def test_trim_capture_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.trim_capture.return_value = {
'status': 'ok',
'capture': {
'id': 'trim_new',
'filename': 'trimmed.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 0.1,
'duration_seconds': 0.3,
})
assert response.status_code == 200
kwargs = mock_mgr.trim_capture.call_args.kwargs
assert kwargs['capture_id'] == 'cap1'
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.3
def test_trim_capture_invalid_param(self, client, auth_client):
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 'bad',
})
assert response.status_code == 400
def test_delete_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_update_capture_label(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'Garage Remote',
})
assert response.status_code == 200
data = response.get_json()
assert data['label'] == 'Garage Remote'
def test_update_capture_label_too_long(self, client, auth_client):
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'x' * 200,
})
assert response.status_code == 400
def test_update_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/nonexistent', json={
'label': 'test',
})
assert response.status_code == 404
# ------ SSE STREAM ------
def test_stream_endpoint(self, client, auth_client):
"""GET /subghz/stream returns SSE response."""
with patch('routes.subghz.sse_stream', return_value=iter([])):
response = auth_client.get('/subghz/stream')
assert response.status_code == 200
assert response.content_type.startswith('text/event-stream')
+643
View File
@@ -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'
+675
View File
@@ -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)
+801
View File
@@ -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']
+779
View File
@@ -0,0 +1,779 @@
"""Tests for weather satellite auto-scheduler.
Covers WeatherSatScheduler class, pass scheduling, timer management,
and automatic capture execution.
"""
from __future__ import annotations
import threading
import time
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock, call
import pytest
from utils.weather_sat_scheduler import (
WeatherSatScheduler,
ScheduledPass,
get_weather_sat_scheduler,
)
class TestScheduledPass:
"""Tests for ScheduledPass class."""
def test_scheduled_pass_initialization(self):
"""ScheduledPass should initialize from pass data."""
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
assert sp.id == 'NOAA-18_202401011200'
assert sp.satellite == 'NOAA-18'
assert sp.name == 'NOAA 18'
assert sp.frequency == 137.9125
assert sp.mode == 'APT'
assert sp.max_el == 45.0
assert sp.duration == 15.0
assert sp.quality == 'good'
assert sp.status == 'scheduled'
assert sp.skipped is False
def test_scheduled_pass_start_dt(self):
"""ScheduledPass.start_dt should parse ISO datetime."""
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
assert sp.start_dt.year == 2024
assert sp.start_dt.month == 1
assert sp.start_dt.day == 1
assert sp.start_dt.hour == 12
assert sp.start_dt.tzinfo == timezone.utc
def test_scheduled_pass_end_dt(self):
"""ScheduledPass.end_dt should parse ISO datetime."""
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
assert sp.end_dt.year == 2024
assert sp.end_dt.minute == 15
def test_scheduled_pass_to_dict(self):
"""ScheduledPass.to_dict() should serialize correctly."""
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp.status = 'complete'
data = sp.to_dict()
assert data['id'] == 'NOAA-18_202401011200'
assert data['satellite'] == 'NOAA-18'
assert data['status'] == 'complete'
assert data['skipped'] is False
class TestWeatherSatScheduler:
"""Tests for WeatherSatScheduler class."""
def test_scheduler_initialization(self):
"""Scheduler should initialize with defaults."""
scheduler = WeatherSatScheduler()
assert scheduler.enabled is False
assert scheduler._lat == 0.0
assert scheduler._lon == 0.0
assert scheduler._min_elevation == 15.0
assert scheduler._device == 0
assert scheduler._gain == 40.0
assert scheduler._bias_t is False
assert scheduler._passes == []
def test_set_callbacks(self):
"""Scheduler should accept callbacks."""
scheduler = WeatherSatScheduler()
progress_cb = MagicMock()
event_cb = MagicMock()
scheduler.set_callbacks(progress_cb, event_cb)
assert scheduler._progress_callback == progress_cb
assert scheduler._event_callback == event_cb
@patch('utils.weather_sat_scheduler.WeatherSatScheduler._refresh_passes')
def test_enable(self, mock_refresh):
"""enable() should start scheduler."""
scheduler = WeatherSatScheduler()
result = scheduler.enable(
lat=51.5,
lon=-0.1,
min_elevation=20.0,
device=1,
gain=35.0,
bias_t=True,
)
assert scheduler._enabled is True
assert scheduler._lat == 51.5
assert scheduler._lon == -0.1
assert scheduler._min_elevation == 20.0
assert scheduler._device == 1
assert scheduler._gain == 35.0
assert scheduler._bias_t is True
mock_refresh.assert_called_once()
assert 'enabled' in result
def test_disable(self):
"""disable() should stop scheduler and cancel timers."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
# Add mock timer
mock_timer = MagicMock()
scheduler._refresh_timer = mock_timer
# Add pass with timer
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp._timer = MagicMock()
sp._stop_timer = MagicMock()
scheduler._passes = [sp]
result = scheduler.disable()
assert scheduler._enabled is False
assert scheduler._passes == []
mock_timer.cancel.assert_called_once()
sp._timer.cancel.assert_called_once()
sp._stop_timer.cancel.assert_called_once()
assert result['status'] == 'disabled'
def test_skip_pass_success(self):
"""skip_pass() should skip a scheduled pass."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp._timer = MagicMock()
scheduler._passes = [sp]
result = scheduler.skip_pass('NOAA-18_202401011200')
assert result is True
assert sp.status == 'skipped'
assert sp.skipped is True
sp._timer.cancel.assert_called_once()
event_cb.assert_called_once()
def test_skip_pass_not_found(self):
"""skip_pass() should return False for non-existent pass."""
scheduler = WeatherSatScheduler()
result = scheduler.skip_pass('NONEXISTENT')
assert result is False
def test_skip_pass_already_complete(self):
"""skip_pass() should not skip already complete passes."""
scheduler = WeatherSatScheduler()
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp.status = 'complete'
scheduler._passes = [sp]
result = scheduler.skip_pass('NOAA-18_202401011200')
assert result is False
assert sp.status == 'complete'
def test_get_status(self):
"""get_status() should return scheduler state."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._device = 0
scheduler._gain = 40.0
scheduler._bias_t = False
scheduler._min_elevation = 15.0
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._passes = [sp]
status = scheduler.get_status()
assert status['enabled'] is True
assert status['observer']['latitude'] == 51.5
assert status['observer']['longitude'] == -0.1
assert status['device'] == 0
assert status['gain'] == 40.0
assert status['bias_t'] is False
assert status['min_elevation'] == 15.0
assert status['scheduled_count'] == 1
assert status['total_passes'] == 1
def test_get_passes(self):
"""get_passes() should return list of scheduled passes."""
scheduler = WeatherSatScheduler()
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._passes = [sp]
passes = scheduler.get_passes()
assert len(passes) == 1
assert passes[0]['id'] == 'NOAA-18_202401011200'
@patch('utils.weather_sat_scheduler.predict_passes')
@patch('threading.Timer')
def test_refresh_passes(self, mock_timer, mock_predict):
"""_refresh_passes() should schedule future passes."""
now = datetime.now(timezone.utc)
future_pass = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now + timedelta(hours=2)).isoformat(),
'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
mock_predict.return_value = [future_pass]
mock_timer_instance = MagicMock()
mock_timer.return_value = mock_timer_instance
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._refresh_passes()
mock_predict.assert_called_once()
assert len(scheduler._passes) == 1
assert scheduler._passes[0].satellite == 'NOAA-18'
mock_timer_instance.start.assert_called()
@patch('utils.weather_sat_scheduler.predict_passes')
def test_refresh_passes_skip_past(self, mock_predict):
"""_refresh_passes() should skip passes that already started."""
now = datetime.now(timezone.utc)
past_pass = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now - timedelta(hours=1)).isoformat(),
'endTimeISO': (now - timedelta(hours=1) + timedelta(minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
mock_predict.return_value = [past_pass]
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._refresh_passes()
# Should not schedule past passes
assert len(scheduler._passes) == 0
@patch('utils.weather_sat_scheduler.predict_passes')
def test_refresh_passes_disabled(self, mock_predict):
"""_refresh_passes() should do nothing when disabled."""
scheduler = WeatherSatScheduler()
scheduler._enabled = False
scheduler._refresh_passes()
mock_predict.assert_not_called()
@patch('utils.weather_sat_scheduler.predict_passes')
def test_refresh_passes_error_handling(self, mock_predict):
"""_refresh_passes() should handle prediction errors."""
mock_predict.side_effect = Exception('TLE error')
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
# Should not raise
scheduler._refresh_passes()
assert len(scheduler._passes) == 0
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_disabled(self, mock_get):
"""_execute_capture() should do nothing when disabled."""
scheduler = WeatherSatScheduler()
scheduler._enabled = False
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
mock_get.assert_not_called()
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_skipped(self, mock_get):
"""_execute_capture() should do nothing for skipped passes."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp.skipped = True
scheduler._execute_capture(sp)
mock_get.assert_not_called()
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_decoder_busy(self, mock_get):
"""_execute_capture() should skip when decoder is busy."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
mock_decoder = MagicMock()
mock_decoder.is_running = True
mock_get.return_value = mock_decoder
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
assert sp.status == 'skipped'
assert sp.skipped is True
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['type'] == 'schedule_capture_skipped'
assert event_data['reason'] == 'sdr_busy'
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer')
def test_execute_capture_success(self, mock_timer, mock_get):
"""_execute_capture() should start capture."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._device = 0
scheduler._gain = 40.0
scheduler._bias_t = False
progress_cb = MagicMock()
event_cb = MagicMock()
scheduler.set_callbacks(progress_cb, event_cb)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_get.return_value = mock_decoder
mock_timer_instance = MagicMock()
mock_timer.return_value = mock_timer_instance
now = datetime.now(timezone.utc)
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now + timedelta(seconds=10)).isoformat(),
'endTimeISO': (now + timedelta(minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
assert sp.status == 'capturing'
mock_decoder.set_callback.assert_called_once_with(progress_cb)
mock_decoder.start.assert_called_once_with(
satellite='NOAA-18',
device_index=0,
gain=40.0,
bias_t=False,
)
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['type'] == 'schedule_capture_start'
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_start_failed(self, mock_get):
"""_execute_capture() should handle start failure."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = False
mock_get.return_value = mock_decoder
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
assert sp.status == 'skipped'
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['reason'] == 'start_failed'
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_stop_capture(self, mock_get):
"""_stop_capture() should stop decoder."""
scheduler = WeatherSatScheduler()
mock_decoder = MagicMock()
mock_decoder.is_running = True
mock_get.return_value = mock_decoder
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._stop_capture(sp)
mock_decoder.stop.assert_called_once()
def test_on_capture_complete(self):
"""_on_capture_complete() should mark pass complete and emit event."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
release_fn = MagicMock()
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._on_capture_complete(sp, release_fn)
assert sp.status == 'complete'
release_fn.assert_called_once()
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['type'] == 'schedule_capture_complete'
def test_emit_event(self):
"""_emit_event() should call event callback."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
event = {'type': 'test_event', 'data': 'test'}
scheduler._emit_event(event)
event_cb.assert_called_once_with(event)
def test_emit_event_no_callback(self):
"""_emit_event() should handle missing callback."""
scheduler = WeatherSatScheduler()
event = {'type': 'test_event'}
scheduler._emit_event(event) # Should not raise
def test_emit_event_callback_exception(self):
"""_emit_event() should handle callback exceptions."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock(side_effect=Exception('Callback error'))
scheduler.set_callbacks(MagicMock(), event_cb)
event = {'type': 'test_event'}
scheduler._emit_event(event) # Should not raise
class TestGlobalScheduler:
"""Tests for global scheduler singleton."""
def test_get_weather_sat_scheduler_singleton(self):
"""get_weather_sat_scheduler() should return singleton."""
import utils.weather_sat_scheduler as mod
old = mod._scheduler
mod._scheduler = None
try:
scheduler1 = get_weather_sat_scheduler()
scheduler2 = get_weather_sat_scheduler()
assert scheduler1 is scheduler2
finally:
mod._scheduler = old
def test_get_weather_sat_scheduler_thread_safe(self):
"""get_weather_sat_scheduler() should be thread-safe."""
import utils.weather_sat_scheduler as mod
old = mod._scheduler
mod._scheduler = None
schedulers = []
def create_scheduler():
schedulers.append(get_weather_sat_scheduler())
try:
threads = [threading.Thread(target=create_scheduler) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# All should be the same instance
assert all(s is schedulers[0] for s in schedulers)
finally:
mod._scheduler = old
class TestSchedulerConfiguration:
"""Tests for scheduler configuration constants."""
def test_config_constants(self):
"""Scheduler should have configuration constants."""
from utils.weather_sat_scheduler import (
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
)
assert isinstance(WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, int)
assert isinstance(WEATHER_SAT_CAPTURE_BUFFER_SECONDS, int)
assert WEATHER_SAT_SCHEDULE_REFRESH_MINUTES > 0
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
class TestSchedulerIntegration:
"""Integration tests for scheduler."""
@patch('utils.weather_sat_scheduler.predict_passes')
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer')
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
"""Test complete scheduling cycle from enable to execute."""
now = datetime.now(timezone.utc)
future_pass = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now + timedelta(hours=2)).isoformat(),
'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
mock_predict.return_value = [future_pass]
mock_timer_instance = MagicMock()
mock_timer.return_value = mock_timer_instance
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_get_decoder.return_value = mock_decoder
scheduler = WeatherSatScheduler()
progress_cb = MagicMock()
event_cb = MagicMock()
scheduler.set_callbacks(progress_cb, event_cb)
# Enable scheduler
result = scheduler.enable(lat=51.5, lon=-0.1)
assert result['enabled'] is True
assert len(scheduler._passes) == 1
assert scheduler._passes[0].satellite == 'NOAA-18'
# Simulate timer firing (capture start)
scheduler._execute_capture(scheduler._passes[0])
assert scheduler._passes[0].status == 'capturing'
mock_decoder.start.assert_called_once()
# Simulate completion
release_fn = MagicMock()
scheduler._on_capture_complete(scheduler._passes[0], release_fn)
assert scheduler._passes[0].status == 'complete'
release_fn.assert_called_once()
# Disable scheduler
scheduler.disable()
assert scheduler.enabled is False
assert len(scheduler._passes) == 0
+198
View File
@@ -0,0 +1,198 @@
"""
IRK Extractor Extract Identity Resolving Keys from paired Bluetooth devices.
Supports macOS (com.apple.Bluetooth.plist) and Linux (BlueZ info files).
"""
from __future__ import annotations
import logging
import platform
import time
from pathlib import Path
logger = logging.getLogger('intercept.bt.irk_extractor')
# Cache paired IRKs for 30 seconds to avoid repeated disk reads
_cache: list[dict] | None = None
_cache_time: float = 0
_CACHE_TTL = 30.0
def get_paired_irks() -> list[dict]:
"""Return paired Bluetooth devices that have IRKs.
Each entry is a dict with keys:
- name: Device name (str or None)
- address: Bluetooth address (str)
- irk_hex: 32-char hex string of the 16-byte IRK
- address_type: 'random' or 'public' (str or None)
Results are cached for 30 seconds.
"""
global _cache, _cache_time
now = time.monotonic()
if _cache is not None and (now - _cache_time) < _CACHE_TTL:
return _cache
system = platform.system()
try:
if system == 'Darwin':
results = _extract_macos()
elif system == 'Linux':
results = _extract_linux()
else:
logger.debug(f"IRK extraction not supported on {system}")
results = []
except Exception:
logger.exception("Failed to extract paired IRKs")
results = []
_cache = results
_cache_time = now
return results
def _extract_macos() -> list[dict]:
"""Extract IRKs from macOS Bluetooth plist."""
import plistlib
plist_path = Path('/Library/Preferences/com.apple.Bluetooth.plist')
if not plist_path.exists():
logger.debug("macOS Bluetooth plist not found")
return []
with open(plist_path, 'rb') as f:
plist = plistlib.load(f)
devices = []
cache_data = plist.get('CoreBluetoothCache', {})
# CoreBluetoothCache contains BLE device info including IRKs
for device_uuid, device_info in cache_data.items():
if not isinstance(device_info, dict):
continue
irk = device_info.get('IRK')
if irk is None:
continue
# IRK is stored as bytes (16 bytes)
if isinstance(irk, bytes) and len(irk) == 16:
irk_hex = irk.hex()
elif isinstance(irk, str):
irk_hex = irk.replace('-', '').replace(' ', '')
if len(irk_hex) != 32:
continue
else:
continue
name = device_info.get('Name') or device_info.get('DeviceName')
address = device_info.get('DeviceAddress', device_uuid)
addr_type = 'random' if device_info.get('AddressType', 1) == 1 else 'public'
devices.append({
'name': name,
'address': str(address),
'irk_hex': irk_hex,
'address_type': addr_type,
})
# Also check LEPairedDevices / PairedDevices structures
for section_key in ('LEPairedDevices', 'PairedDevices'):
section = plist.get(section_key, {})
if not isinstance(section, dict):
continue
for addr, dev_info in section.items():
if not isinstance(dev_info, dict):
continue
irk = dev_info.get('IRK') or dev_info.get('IdentityResolvingKey')
if irk is None:
continue
if isinstance(irk, bytes) and len(irk) == 16:
irk_hex = irk.hex()
elif isinstance(irk, str):
irk_hex = irk.replace('-', '').replace(' ', '')
if len(irk_hex) != 32:
continue
else:
continue
# Skip if we already have this IRK
if any(d['irk_hex'] == irk_hex for d in devices):
continue
name = dev_info.get('Name') or dev_info.get('DeviceName')
addr_type = 'random' if dev_info.get('AddressType', 1) == 1 else 'public'
devices.append({
'name': name,
'address': str(addr),
'irk_hex': irk_hex,
'address_type': addr_type,
})
logger.info(f"Extracted {len(devices)} IRK(s) from macOS paired devices")
return devices
def _extract_linux() -> list[dict]:
"""Extract IRKs from Linux BlueZ info files.
BlueZ stores paired device info at:
/var/lib/bluetooth/<adapter_mac>/<device_mac>/info
"""
import configparser
bt_root = Path('/var/lib/bluetooth')
if not bt_root.exists():
logger.debug("BlueZ bluetooth directory not found")
return []
devices = []
for adapter_dir in bt_root.iterdir():
if not adapter_dir.is_dir():
continue
for device_dir in adapter_dir.iterdir():
if not device_dir.is_dir():
continue
info_file = device_dir / 'info'
if not info_file.exists():
continue
config = configparser.ConfigParser()
try:
config.read(str(info_file))
except (configparser.Error, OSError):
continue
if not config.has_section('IdentityResolvingKey'):
continue
irk_hex = config.get('IdentityResolvingKey', 'Key', fallback=None)
if not irk_hex:
continue
# BlueZ stores as hex string, may or may not have separators
irk_hex = irk_hex.replace(' ', '').replace('-', '')
if len(irk_hex) != 32:
continue
name = config.get('General', 'Name', fallback=None)
address = device_dir.name # Directory name is the MAC address
addr_type = config.get('General', 'AddressType', fallback=None)
devices.append({
'name': name,
'address': address,
'irk_hex': irk_hex,
'address_type': addr_type,
})
logger.info(f"Extracted {len(devices)} IRK(s) from BlueZ paired devices")
return devices
+37 -8
View File
@@ -66,7 +66,7 @@ class BluetoothScanner:
self._scan_timer: Optional[threading.Timer] = None
# Callbacks
self._on_device_updated: Optional[Callable[[BTDeviceAggregate], None]] = None
self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = []
# Capability check result
self._capabilities: Optional[SystemCapabilities] = None
@@ -236,9 +236,12 @@ class BluetoothScanner:
'device': device.to_summary_dict(),
})
# Callback
if self._on_device_updated:
self._on_device_updated(device)
# Callbacks
for cb in self._on_device_updated_callbacks:
try:
cb(device)
except Exception as cb_err:
logger.error(f"Device callback error: {cb_err}")
except Exception as e:
logger.error(f"Error handling observation: {e}")
@@ -368,13 +371,39 @@ class BluetoothScanner:
return self._capabilities
def set_on_device_updated(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
"""Set callback for device updates."""
self._on_device_updated = callback
"""Set callback for device updates (legacy, adds to callback list)."""
self.add_device_callback(callback)
def add_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
"""Add a callback for device updates."""
if callback not in self._on_device_updated_callbacks:
self._on_device_updated_callbacks.append(callback)
def remove_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
"""Remove a device update callback."""
if callback in self._on_device_updated_callbacks:
self._on_device_updated_callbacks.remove(callback)
@property
def is_scanning(self) -> bool:
"""Check if scanning is active."""
return self._status.is_scanning
"""Check if scanning is active.
Cross-checks the backend scanner state, since bleak scans can
expire silently without calling stop_scan().
"""
if not self._status.is_scanning:
return False
# Detect backends that finished on their own (e.g. bleak timeout)
backend_alive = (
(self._dbus_scanner and self._dbus_scanner.is_scanning)
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
)
if not backend_alive:
self._status.is_scanning = False
return False
return True
@property
def device_count(self) -> int:
+562
View File
@@ -0,0 +1,562 @@
"""
BT Locate Bluetooth SAR Device Location System.
Provides GPS-tagged signal trail mapping, RPA resolution, environment-aware
distance estimation, and proximity alerts for search and rescue operations.
"""
from __future__ import annotations
import logging
import queue
import threading
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from utils.bluetooth.models import BTDeviceAggregate
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
from utils.gps import get_current_position
logger = logging.getLogger('intercept.bt_locate')
# Maximum trail points to retain
MAX_TRAIL_POINTS = 500
# EMA smoothing factor for RSSI
EMA_ALPHA = 0.3
class Environment(Enum):
"""RF propagation environment presets."""
FREE_SPACE = 2.0
OUTDOOR = 2.2
INDOOR = 3.0
CUSTOM = 0.0 # user-provided exponent
def resolve_rpa(irk: bytes, address: str) -> bool:
"""
Resolve a BLE Resolvable Private Address against an Identity Resolving Key.
Implements the Bluetooth Core Spec ah() function using AES-128-ECB.
Args:
irk: 16-byte Identity Resolving Key.
address: BLE address string (e.g. 'AA:BB:CC:DD:EE:FF').
Returns:
True if the address resolves against the IRK.
"""
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
except ImportError:
logger.error("cryptography package required for RPA resolution")
return False
# Parse address bytes (remove colons, convert to bytes)
addr_bytes = bytes.fromhex(address.replace(':', '').replace('-', ''))
if len(addr_bytes) != 6:
return False
# RPA: upper 2 bits of MSB must be 01 (resolvable)
if (addr_bytes[0] >> 6) != 1:
return False
# prand = upper 3 bytes (MSB first), hash = lower 3 bytes
prand = addr_bytes[0:3]
expected_hash = addr_bytes[3:6]
# ah(k, r) = e(k, r') mod 2^24
# r' is prand zero-padded to 16 bytes (MSB)
plaintext = b'\x00' * 13 + prand
cipher = Cipher(algorithms.AES(irk), modes.ECB())
encryptor = cipher.encryptor()
encrypted = encryptor.update(plaintext) + encryptor.finalize()
# Take last 3 bytes as hash
computed_hash = encrypted[13:16]
return computed_hash == expected_hash
@dataclass
class LocateTarget:
"""Target device specification for locate session."""
mac_address: str | None = None
name_pattern: str | None = None
irk_hex: str | None = None
device_id: str | None = None
# Hand-off metadata from Bluetooth mode
known_name: str | None = None
known_manufacturer: str | None = None
last_known_rssi: int | None = None
def matches(self, device: BTDeviceAggregate) -> bool:
"""Check if a device matches this target."""
# Match by device_id (exact)
if self.device_id and device.device_id == self.device_id:
return True
# Match by device_id address portion (without :address_type suffix)
if self.device_id and ':' in self.device_id:
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
dev_addr = (device.address or '').upper()
if target_addr_part and dev_addr == target_addr_part:
return True
# Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address:
dev_addr = (device.address or '').upper().replace('-', ':')
target_addr = self.mac_address.upper().replace('-', ':')
if dev_addr == target_addr:
return True
# Match by RPA resolution
if self.irk_hex:
try:
irk = bytes.fromhex(self.irk_hex)
if len(irk) == 16 and device.address and resolve_rpa(irk, device.address):
return True
except (ValueError, TypeError):
pass
# Match by name pattern
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
return True
# Match by known_name from handoff (exact name match)
return bool(self.known_name and device.name and self.known_name.lower() == device.name.lower())
def to_dict(self) -> dict:
return {
'mac_address': self.mac_address,
'name_pattern': self.name_pattern,
'irk_hex': self.irk_hex,
'device_id': self.device_id,
'known_name': self.known_name,
'known_manufacturer': self.known_manufacturer,
'last_known_rssi': self.last_known_rssi,
}
class DistanceEstimator:
"""Estimate distance from RSSI using log-distance path loss model."""
# Reference RSSI at 1 meter (typical BLE)
RSSI_AT_1M = -59
def __init__(self, path_loss_exponent: float = 2.0, rssi_at_1m: int = -59):
self.n = path_loss_exponent
self.rssi_at_1m = rssi_at_1m
def estimate(self, rssi: int) -> float:
"""Estimate distance in meters from RSSI."""
if rssi >= 0 or self.n <= 0:
return 0.0
return 10 ** ((self.rssi_at_1m - rssi) / (10 * self.n))
@staticmethod
def proximity_band(distance: float) -> str:
"""Classify distance into proximity band."""
if distance <= 1.0:
return 'IMMEDIATE'
elif distance <= 5.0:
return 'NEAR'
else:
return 'FAR'
@dataclass
class DetectionPoint:
"""A single GPS-tagged BLE detection."""
timestamp: str
rssi: int
rssi_ema: float
estimated_distance: float
proximity_band: str
lat: float | None = None
lon: float | None = None
gps_accuracy: float | None = None
rpa_resolved: bool = False
def to_dict(self) -> dict:
return {
'timestamp': self.timestamp,
'rssi': self.rssi,
'rssi_ema': round(self.rssi_ema, 1),
'estimated_distance': round(self.estimated_distance, 2),
'proximity_band': self.proximity_band,
'lat': self.lat,
'lon': self.lon,
'gps_accuracy': self.gps_accuracy,
'rpa_resolved': self.rpa_resolved,
}
class LocateSession:
"""Active locate session tracking a target device."""
def __init__(
self,
target: LocateTarget,
environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None,
fallback_lat: float | None = None,
fallback_lon: float | None = None,
):
self.target = target
self.environment = environment
self.fallback_lat = fallback_lat
self.fallback_lon = fallback_lon
self._lock = threading.Lock()
# Distance estimator
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
self.estimator = DistanceEstimator(path_loss_exponent=n)
# Signal trail
self.trail: list[DetectionPoint] = []
# RSSI EMA state
self._rssi_ema: float | None = None
# SSE event queue
self.event_queue: queue.Queue = queue.Queue(maxsize=500)
# Session state
self.active = False
self.started_at: datetime | None = None
self.detection_count = 0
self.last_detection: datetime | None = None
# Debug counters
self.callback_call_count = 0
self.poll_count = 0
self._last_seen_device: str | None = None
# Scanner reference
self._scanner: BluetoothScanner | None = None
self._poll_thread: threading.Thread | None = None
self._stop_event = threading.Event()
# Track last RSSI per device to detect changes
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
def start(self) -> bool:
"""Start the locate session.
Subscribes to scanner callbacks AND runs a polling thread that
checks the aggregator directly (handles bleak scan timeout).
"""
self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device)
# Ensure BLE scanning is active
if not self._scanner.is_scanning:
logger.info("BT scanner not running, starting scan for locate session")
self._scanner_started_by_us = True
if not self._scanner.start_scan(mode='auto'):
logger.warning("Failed to start BT scanner for locate session")
else:
self._scanner_started_by_us = False
self.active = True
self.started_at = datetime.now()
self._stop_event.clear()
# Start polling thread as reliable fallback
self._poll_thread = threading.Thread(
target=self._poll_loop, daemon=True, name='bt-locate-poll'
)
self._poll_thread.start()
logger.info(f"Locate session started for target: {self.target.to_dict()}")
return True
def stop(self) -> None:
"""Stop the locate session."""
self.active = False
self._stop_event.set()
if self._scanner:
self._scanner.remove_device_callback(self._on_device)
if getattr(self, '_scanner_started_by_us', False) and self._scanner.is_scanning:
self._scanner.stop_scan()
logger.info("Stopped BT scanner (was started by locate session)")
if self._poll_thread:
self._poll_thread.join(timeout=3.0)
logger.info("Locate session stopped")
def _poll_loop(self) -> None:
"""Poll scanner aggregator for target device updates."""
while not self._stop_event.is_set():
self._stop_event.wait(timeout=1.5)
if self._stop_event.is_set():
break
try:
self._check_aggregator()
except Exception as e:
logger.error(f"Locate poll error: {e}")
def _check_aggregator(self) -> None:
"""Check the scanner's aggregator for the target device."""
if not self._scanner:
return
self.poll_count += 1
# Restart scan if it expired (bleak 10s timeout)
if not self._scanner.is_scanning:
logger.info("Scanner stopped, restarting for locate session")
self._scanner.start_scan(mode='auto')
# Check devices seen within a recent window. Using a short window
# (rather than the aggregator's full 120s) so that once a device
# goes silent its stale RSSI stops producing detections. The window
# must survive bleak's 10s scan cycle + restart gap (~3s).
devices = self._scanner.get_devices(max_age_seconds=15)
found_target = False
for device in devices:
if not self.target.matches(device):
continue
found_target = True
rssi = device.rssi_current
if rssi is None:
continue
self._record_detection(device, rssi)
break # One match per poll cycle is sufficient
# Log periodically for debugging
if self.poll_count % 20 == 0 or (self.poll_count <= 5) or not found_target:
logger.info(
f"Poll #{self.poll_count}: {len(devices)} devices, "
f"target_found={found_target}, "
f"detections={self.detection_count}, "
f"scanning={self._scanner.is_scanning}"
)
def _on_device(self, device: BTDeviceAggregate) -> None:
"""Scanner callback: check if device matches target."""
if not self.active:
return
self.callback_call_count += 1
self._last_seen_device = f"{device.device_id}|{device.name}"
if not self.target.matches(device):
return
rssi = device.rssi_current
if rssi is None:
return
# Dedup rapid callbacks (bleak can fire many times per second)
prev = self._last_cb_rssi.get(device.device_id)
if prev == rssi:
return
self._last_cb_rssi[device.device_id] = rssi
self._record_detection(device, rssi)
def _record_detection(self, device: BTDeviceAggregate, rssi: int) -> None:
"""Record a target detection with GPS tagging."""
logger.info(f"Target detected: {device.address} RSSI={rssi} name={device.name}")
# Update EMA
if self._rssi_ema is None:
self._rssi_ema = float(rssi)
else:
self._rssi_ema = EMA_ALPHA * rssi + (1 - EMA_ALPHA) * self._rssi_ema
# Estimate distance
distance = self.estimator.estimate(rssi)
band = DistanceEstimator.proximity_band(distance)
# Check RPA resolution
rpa_resolved = False
if self.target.irk_hex and device.address:
try:
irk = bytes.fromhex(self.target.irk_hex)
rpa_resolved = resolve_rpa(irk, device.address)
except (ValueError, TypeError):
pass
# GPS tag — prefer live GPS, fall back to user-set coordinates
gps_pos = get_current_position()
lat = gps_pos.latitude if gps_pos else None
lon = gps_pos.longitude if gps_pos else None
gps_acc = None
if gps_pos:
epx = gps_pos.epx or 0
epy = gps_pos.epy or 0
if epx or epy:
gps_acc = round(max(epx, epy), 1)
elif self.fallback_lat is not None and self.fallback_lon is not None:
lat = self.fallback_lat
lon = self.fallback_lon
now = datetime.now()
point = DetectionPoint(
timestamp=now.isoformat(),
rssi=rssi,
rssi_ema=self._rssi_ema,
estimated_distance=distance,
proximity_band=band,
lat=lat,
lon=lon,
gps_accuracy=gps_acc,
rpa_resolved=rpa_resolved,
)
with self._lock:
self.trail.append(point)
if len(self.trail) > MAX_TRAIL_POINTS:
self.trail = self.trail[-MAX_TRAIL_POINTS:]
self.detection_count += 1
self.last_detection = now
# Queue SSE event
event = {
'type': 'detection',
'data': point.to_dict(),
'device_name': device.name,
'device_address': device.address,
}
try:
self.event_queue.put_nowait(event)
except queue.Full:
try:
self.event_queue.get_nowait()
self.event_queue.put_nowait(event)
except queue.Empty:
pass
def get_trail(self) -> list[dict]:
"""Get the full detection trail."""
with self._lock:
return [p.to_dict() for p in self.trail]
def get_gps_trail(self) -> list[dict]:
"""Get only trail points that have GPS coordinates."""
with self._lock:
return [p.to_dict() for p in self.trail if p.lat is not None]
def get_status(self) -> dict:
"""Get session status."""
gps_pos = get_current_position()
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
# deadlock: get_status would hold self._lock then wait on
# aggregator._lock, while _poll_loop holds aggregator._lock then
# waits on self._lock in _record_detection.
debug_devices = self._debug_device_sample()
scanner_running = self._scanner.is_scanning if self._scanner else False
scanner_device_count = self._scanner.device_count if self._scanner else 0
callback_registered = (
self._on_device in self._scanner._on_device_updated_callbacks
if self._scanner else False
)
with self._lock:
return {
'active': self.active,
'target': self.target.to_dict(),
'environment': self.environment.name,
'path_loss_exponent': self.estimator.n,
'started_at': self.started_at.isoformat() if self.started_at else None,
'detection_count': self.detection_count,
'gps_trail_count': sum(1 for p in self.trail if p.lat is not None),
'last_detection': self.last_detection.isoformat() if self.last_detection else None,
'scanner_running': scanner_running,
'scanner_device_count': scanner_device_count,
'callback_registered': callback_registered,
'event_queue_size': self.event_queue.qsize(),
'callback_call_count': self.callback_call_count,
'poll_count': self.poll_count,
'poll_thread_alive': self._poll_thread.is_alive() if self._poll_thread else False,
'last_seen_device': self._last_seen_device,
'gps_available': gps_pos is not None,
'gps_source': 'live' if gps_pos else (
'manual' if self.fallback_lat is not None else 'none'
),
'fallback_lat': self.fallback_lat,
'fallback_lon': self.fallback_lon,
'latest_rssi': self.trail[-1].rssi if self.trail else None,
'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None,
'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None,
'latest_band': self.trail[-1].proximity_band if self.trail else None,
'debug_devices': debug_devices,
}
def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None:
"""Update the environment and recalculate distance estimator."""
with self._lock:
self.environment = environment
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
self.estimator = DistanceEstimator(path_loss_exponent=n)
def _debug_device_sample(self) -> list[dict]:
"""Return a sample of scanner devices for debugging matching issues."""
if not self._scanner:
return []
try:
devices = self._scanner.get_devices(max_age_seconds=30)
return [
{
'id': d.device_id,
'addr': d.address,
'name': d.name,
'rssi': d.rssi_current,
'match': self.target.matches(d),
}
for d in devices[:8]
]
except Exception:
return []
def clear_trail(self) -> None:
"""Clear the detection trail."""
with self._lock:
self.trail.clear()
self.detection_count = 0
# Module-level session management (single active session)
_session: LocateSession | None = None
_session_lock = threading.Lock()
def start_locate_session(
target: LocateTarget,
environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None,
fallback_lat: float | None = None,
fallback_lon: float | None = None,
) -> LocateSession:
"""Start a new locate session, stopping any existing one."""
global _session
with _session_lock:
if _session and _session.active:
_session.stop()
_session = LocateSession(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
_session.start()
return _session
def stop_locate_session() -> None:
"""Stop the active locate session."""
global _session
with _session_lock:
if _session:
_session.stop()
_session = None
def get_locate_session() -> LocateSession | None:
"""Get the current locate session (if any)."""
with _session_lock:
return _session
+44
View File
@@ -256,6 +256,50 @@ MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
DSC_TERMINATE_TIMEOUT = 3
# =============================================================================
# SUBGHZ TRANSCEIVER (HackRF)
# =============================================================================
# Allowed ISM TX frequency bands (MHz) - transmit only within these ranges
SUBGHZ_TX_ALLOWED_BANDS = [
(300.0, 348.0), # 315 MHz ISM band
(387.0, 464.0), # 433 MHz ISM band
(779.0, 928.0), # 868/915 MHz ISM band
]
# HackRF frequency limits (MHz)
SUBGHZ_FREQ_MIN_MHZ = 1.0
SUBGHZ_FREQ_MAX_MHZ = 6000.0
# HackRF gain ranges
SUBGHZ_LNA_GAIN_MIN = 0
SUBGHZ_LNA_GAIN_MAX = 40
SUBGHZ_VGA_GAIN_MIN = 0
SUBGHZ_VGA_GAIN_MAX = 62
SUBGHZ_TX_VGA_GAIN_MIN = 0
SUBGHZ_TX_VGA_GAIN_MAX = 47
# Default sample rates available (Hz)
SUBGHZ_SAMPLE_RATES = [2000000, 4000000, 8000000, 10000000, 20000000]
# Maximum TX duration watchdog (seconds)
SUBGHZ_TX_MAX_DURATION = 30
# Sweep defaults
SUBGHZ_SWEEP_BIN_WIDTH = 100000 # 100 kHz bins
# SubGHz process termination timeout
SUBGHZ_TERMINATE_TIMEOUT = 3
# Common SubGHz preset frequencies (MHz)
SUBGHZ_PRESETS = {
'315 MHz': 315.0,
'433.92 MHz': 433.92,
'868 MHz': 868.0,
'915 MHz': 915.0,
}
# =============================================================================
# DEAUTH ATTACK DETECTION
# =============================================================================
+132
View File
@@ -531,6 +531,30 @@ def init_db() -> None:
ON push_payloads(agent_id, received_at)
''')
# Tracked satellites table for persistent satellite management
conn.execute('''
CREATE TABLE IF NOT EXISTS tracked_satellites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
norad_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
tle_line1 TEXT,
tle_line2 TEXT,
enabled BOOLEAN DEFAULT 1,
builtin BOOLEAN DEFAULT 0,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Seed builtin satellites if not already present
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1)
''')
conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
''')
logger.info("Database initialized successfully")
@@ -2170,3 +2194,111 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
''', (f'-{max_age_hours} hours',))
return cursor.rowcount
# =============================================================================
# Tracked Satellites Functions
# =============================================================================
def get_tracked_satellites(enabled_only: bool = False) -> list[dict]:
"""Return all tracked satellites, optionally filtered to enabled only."""
with get_db() as conn:
if enabled_only:
rows = conn.execute(
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
'FROM tracked_satellites WHERE enabled = 1 ORDER BY builtin DESC, name'
).fetchall()
else:
rows = conn.execute(
'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at '
'FROM tracked_satellites ORDER BY builtin DESC, name'
).fetchall()
return [
{
'norad_id': r[0],
'name': r[1],
'tle_line1': r[2],
'tle_line2': r[3],
'enabled': bool(r[4]),
'builtin': bool(r[5]),
'added_at': r[6],
}
for r in rows
]
def add_tracked_satellite(
norad_id: str,
name: str,
tle_line1: str | None = None,
tle_line2: str | None = None,
enabled: bool = True,
builtin: bool = False,
) -> bool:
"""Insert a tracked satellite. Returns True if inserted, False if duplicate."""
with get_db() as conn:
try:
conn.execute(
'INSERT OR IGNORE INTO tracked_satellites '
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
'VALUES (?, ?, ?, ?, ?, ?)',
(str(norad_id), name, tle_line1, tle_line2, int(enabled), int(builtin)),
)
return conn.total_changes > 0
except sqlite3.Error as e:
logger.error(f"Error adding tracked satellite {norad_id}: {e}")
return False
def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int:
"""Insert many tracked satellites at once. Returns count of newly inserted."""
added = 0
with get_db() as conn:
for sat in satellites_list:
try:
cursor = conn.execute(
'INSERT OR IGNORE INTO tracked_satellites '
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
'VALUES (?, ?, ?, ?, ?, ?)',
(
str(sat['norad_id']),
sat['name'],
sat.get('tle_line1'),
sat.get('tle_line2'),
int(sat.get('enabled', True)),
int(sat.get('builtin', False)),
),
)
if cursor.rowcount > 0:
added += 1
except (sqlite3.Error, KeyError) as e:
logger.warning(f"Error bulk-adding satellite: {e}")
return added
def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
"""Toggle enabled state for a tracked satellite."""
with get_db() as conn:
cursor = conn.execute(
'UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?',
(int(enabled), str(norad_id)),
)
return cursor.rowcount > 0
def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
"""Delete a tracked satellite by NORAD ID. Refuses to delete builtins."""
with get_db() as conn:
row = conn.execute(
'SELECT builtin FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
).fetchone()
if row is None:
return False, 'Satellite not found'
if row[0]:
return False, 'Cannot remove builtin satellite'
conn.execute(
'DELETE FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),),
)
return True, 'Removed'
+32
View File
@@ -394,6 +394,38 @@ TOOL_DEPENDENCIES = {
}
}
},
'subghz': {
'name': 'SubGHz Transceiver',
'tools': {
'hackrf_transfer': {
'required': True,
'description': 'HackRF IQ capture and replay',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'hackrf_sweep': {
'required': False,
'description': 'HackRF wideband spectrum sweep',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'rtl_433': {
'required': False,
'description': 'Protocol decoder for SubGHz signals',
'install': {
'apt': 'sudo apt install rtl-433',
'brew': 'brew install rtl_433',
'manual': 'https://github.com/merbanan/rtl_433'
}
}
}
},
'tscm': {
'name': 'TSCM Counter-Surveillance',
'tools': {
+17 -2
View File
@@ -264,6 +264,20 @@ class DSCDecoder:
symbol_value = self._bits_to_symbol(symbol_bits)
symbols.append(symbol_value)
# Strip phasing sequence (RX/DX symbols 120-126) from the
# start of the message. Per ITU-R M.493, after the dot pattern
# there are 7 phasing symbols before the format specifier.
msg_start = 0
for i, sym in enumerate(symbols):
if 120 <= sym <= 126:
msg_start = i + 1
else:
break
symbols = symbols[msg_start:]
if len(symbols) < 5:
return None
# Look for EOS (End of Sequence) - symbol 127
eos_found = False
eos_index = -1
@@ -404,9 +418,10 @@ class DSCDecoder:
digits.append(f'{sym:02d}')
mmsi = ''.join(digits)
# MMSI is 9 digits, might need to trim leading zero
# MMSI is 9 digits - trim the leading digit from the 10-digit
# BCD result since the first symbol's high digit is always 0
if len(mmsi) > 9:
mmsi = mmsi[-9:]
mmsi = mmsi[1:]
return mmsi.zfill(9)
+183 -30
View File
@@ -6,28 +6,86 @@ Provides GPS location data by connecting to the gpsd daemon.
from __future__ import annotations
import contextlib
import logging
import socket as _socket_mod
import threading
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Callable
from typing import Callable
logger = logging.getLogger('intercept.gps')
@dataclass
class GPSSatellite:
"""Individual satellite data from gpsd SKY message."""
prn: int
elevation: float | None = None # degrees
azimuth: float | None = None # degrees
snr: float | None = None # dB-Hz
used: bool = False
constellation: str = 'GPS' # GPS, GLONASS, Galileo, BeiDou, SBAS, QZSS
def to_dict(self) -> dict:
return {
'prn': self.prn,
'elevation': self.elevation,
'azimuth': self.azimuth,
'snr': self.snr,
'used': self.used,
'constellation': self.constellation,
}
@dataclass
class GPSSkyData:
"""Sky view data from gpsd SKY message."""
satellites: list[GPSSatellite] = field(default_factory=list)
hdop: float | None = None
vdop: float | None = None
pdop: float | None = None
tdop: float | None = None
gdop: float | None = None
xdop: float | None = None
ydop: float | None = None
nsat: int = 0 # total visible
usat: int = 0 # total used
def to_dict(self) -> dict:
return {
'satellites': [s.to_dict() for s in self.satellites],
'hdop': self.hdop,
'vdop': self.vdop,
'pdop': self.pdop,
'tdop': self.tdop,
'gdop': self.gdop,
'xdop': self.xdop,
'ydop': self.ydop,
'nsat': self.nsat,
'usat': self.usat,
}
@dataclass
class GPSPosition:
"""GPS position data."""
latitude: float
longitude: float
altitude: Optional[float] = None
speed: Optional[float] = None # m/s
heading: Optional[float] = None # degrees
satellites: Optional[int] = None
altitude: float | None = None
speed: float | None = None # m/s
heading: float | None = None # degrees
climb: float | None = None # m/s vertical speed
satellites: int | None = None
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
timestamp: Optional[datetime] = None
device: Optional[str] = None
timestamp: datetime | None = None
device: str | None = None
# Error estimates
epx: float | None = None # lon error (m)
epy: float | None = None # lat error (m)
epv: float | None = None # vertical error (m)
eps: float | None = None # speed error (m/s)
ept: float | None = None # time error (s)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
@@ -37,13 +95,45 @@ class GPSPosition:
'altitude': self.altitude,
'speed': self.speed,
'heading': self.heading,
'climb': self.climb,
'satellites': self.satellites,
'fix_quality': self.fix_quality,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'device': self.device,
'epx': self.epx,
'epy': self.epy,
'epv': self.epv,
'eps': self.eps,
'ept': self.ept,
}
def _classify_constellation(prn: int, gnssid: int | None = None) -> str:
"""Classify satellite constellation from PRN or gnssid."""
if gnssid is not None:
mapping = {
0: 'GPS', 1: 'SBAS', 2: 'Galileo', 3: 'BeiDou',
4: 'IMES', 5: 'QZSS', 6: 'GLONASS', 7: 'NavIC',
}
return mapping.get(gnssid, 'GPS')
# Fall back to PRN range heuristic
if 1 <= prn <= 32:
return 'GPS'
elif 33 <= prn <= 64:
return 'SBAS'
elif 65 <= prn <= 96:
return 'GLONASS'
elif 120 <= prn <= 158:
return 'SBAS'
elif 201 <= prn <= 264:
return 'BeiDou'
elif 301 <= prn <= 336:
return 'Galileo'
elif 193 <= prn <= 200:
return 'QZSS'
return 'GPS'
class GPSDClient:
"""
Connects to gpsd daemon for GPS data.
@@ -58,35 +148,43 @@ class GPSDClient:
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
self.host = host
self.port = port
self._position: Optional[GPSPosition] = None
self._position: GPSPosition | None = None
self._sky: GPSSkyData | None = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._socket: Optional['socket.socket'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._thread: threading.Thread | None = None
self._socket: _socket_mod.socket | None = None
self._last_update: datetime | None = None
self._error: str | None = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
self._device: Optional[str] = None
self._sky_callbacks: list[Callable[[GPSSkyData], None]] = []
self._device: str | None = None
@property
def position(self) -> Optional[GPSPosition]:
def position(self) -> GPSPosition | None:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def sky(self) -> GPSSkyData | None:
"""Get the current sky view data."""
with self._lock:
return self._sky
@property
def is_running(self) -> bool:
"""Check if the client is running."""
return self._running
@property
def last_update(self) -> Optional[datetime]:
def last_update(self) -> datetime | None:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> Optional[str]:
def error(self) -> str | None:
"""Get any error message."""
with self._lock:
return self._error
@@ -105,6 +203,15 @@ class GPSDClient:
if callback in self._callbacks:
self._callbacks.remove(callback)
def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
"""Add a callback to be called on sky data updates."""
self._sky_callbacks.append(callback)
def remove_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
"""Remove a sky data update callback."""
if callback in self._sky_callbacks:
self._sky_callbacks.remove(callback)
def start(self) -> bool:
"""Start receiving GPS data from gpsd."""
import socket
@@ -135,10 +242,8 @@ class GPSDClient:
self._error = str(e)
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
if self._socket:
try:
with contextlib.suppress(Exception):
self._socket.close()
except Exception:
pass
self._socket = None
return False
@@ -169,7 +274,7 @@ class GPSDClient:
buffer = ""
message_count = 0
print(f"[GPS] gpsd read loop started", flush=True)
print("[GPS] gpsd read loop started", flush=True)
while self._running and self._socket:
try:
@@ -202,6 +307,8 @@ class GPSDClient:
if msg_class == 'TPV':
self._handle_tpv(msg)
elif msg_class == 'SKY':
self._handle_sky(msg)
elif msg_class == 'DEVICES':
# Track connected device
devices = msg.get('devices', [])
@@ -239,11 +346,9 @@ class GPSDClient:
timestamp = None
time_str = msg.get('time')
if time_str:
try:
with contextlib.suppress(ValueError, AttributeError):
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
pass
position = GPSPosition(
latitude=lat,
@@ -251,14 +356,58 @@ class GPSDClient:
altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd
heading=msg.get('track'),
climb=msg.get('climb'),
fix_quality=mode,
timestamp=timestamp,
device=self._device or f"gpsd://{self.host}:{self.port}",
epx=msg.get('epx'),
epy=msg.get('epy'),
epv=msg.get('epv'),
eps=msg.get('eps'),
ept=msg.get('ept'),
)
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
self._update_position(position)
def _handle_sky(self, msg: dict) -> None:
"""Handle SKY (satellite sky view) message from gpsd."""
sats = []
for sat in msg.get('satellites', []):
prn = sat.get('PRN', 0)
gnssid = sat.get('gnssid')
sats.append(GPSSatellite(
prn=prn,
elevation=sat.get('el'),
azimuth=sat.get('az'),
snr=sat.get('ss'),
used=sat.get('used', False),
constellation=_classify_constellation(prn, gnssid),
))
sky_data = GPSSkyData(
satellites=sats,
hdop=msg.get('hdop'),
vdop=msg.get('vdop'),
pdop=msg.get('pdop'),
tdop=msg.get('tdop'),
gdop=msg.get('gdop'),
xdop=msg.get('xdop'),
ydop=msg.get('ydop'),
nsat=len(sats),
usat=sum(1 for s in sats if s.used),
)
with self._lock:
self._sky = sky_data
# Notify sky callbacks
for callback in self._sky_callbacks:
try:
callback(sky_data)
except Exception as e:
logger.error(f"GPS sky callback error: {e}")
def _update_position(self, position: GPSPosition) -> None:
"""Update the current position and notify callbacks."""
with self._lock:
@@ -275,18 +424,19 @@ class GPSDClient:
# Global GPS client instance
_gps_client: Optional[GPSDClient] = None
_gps_client: GPSDClient | None = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSDClient]:
def get_gps_reader() -> GPSDClient | None:
"""Get the global GPS client instance."""
with _gps_lock:
return _gps_client
def start_gpsd(host: str = 'localhost', port: int = 2947,
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
callback: Callable[[GPSPosition], None] | None = None,
sky_callback: Callable[[GPSSkyData], None] | None = None) -> bool:
"""
Start the global GPS client connected to gpsd.
@@ -294,6 +444,7 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
host: gpsd host (default localhost)
port: gpsd port (default 2947)
callback: Optional callback for position updates
sky_callback: Optional callback for sky data updates
Returns:
True if started successfully
@@ -307,9 +458,11 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
_gps_client = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition
# Register callbacks BEFORE starting to avoid race condition
if callback:
_gps_client.add_callback(callback)
if sky_callback:
_gps_client.add_sky_callback(sky_callback)
return _gps_client.start()
@@ -324,7 +477,7 @@ def stop_gps() -> None:
_gps_client = None
def get_current_position() -> Optional[GPSPosition]:
def get_current_position() -> GPSPosition | None:
"""Get the current GPS position from the global client."""
client = get_gps_reader()
if client:
+51 -20
View File
@@ -6,15 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations
import logging
import re
import shutil
import subprocess
from typing import Optional
import logging
import re
import shutil
import subprocess
import time
from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception:
return False
def _check_tool(name: str) -> bool:
@@ -295,16 +311,29 @@ def _add_soapy_device(
))
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
return devices
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
global _hackrf_cache, _hackrf_cache_ts
now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked():
return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache)
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
try:
result = subprocess.run(
@@ -342,10 +371,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
capabilities=HackRFCommandBuilder.CAPABILITIES
))
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
return devices
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices)
_hackrf_cache_ts = now
return devices
def probe_rtlsdr_device(device_index: int) -> str | None:
+19 -12
View File
@@ -14,10 +14,16 @@ from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map app/UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
@@ -87,14 +93,15 @@ class RTLSDRCommandBuilder(CommandBuilder):
Used for pager decoding. Supports local devices and rtl_tcp connections.
"""
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
cmd = [
rtl_fm_path,
'-d', self._get_device_arg(device),
'-f', f'{frequency_mhz}M',
'-M', modulation,
'-s', str(sample_rate),
]
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
demod_mode = _rtl_fm_demod_mode(modulation)
cmd = [
rtl_fm_path,
'-d', self._get_device_arg(device),
'-f', f'{frequency_mhz}M',
'-M', demod_mode,
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(gain)])
+4
View File
@@ -311,6 +311,10 @@ class VISDetector:
if len(self._data_bits) != 8:
return None
# VIS uses even parity across 8 data bits + parity bit.
if (sum(self._data_bits) + self._parity_bit) % 2 != 0:
return None
# Decode VIS code (LSB first)
vis_code = 0
for i, bit in enumerate(self._data_bits):
+2809
View File
File diff suppressed because it is too large Load Diff
+1051
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
"""Weather satellite pass prediction utility.
Shared prediction logic used by both the API endpoint and the auto-scheduler.
"""
from __future__ import annotations
import datetime
from typing import Any
from utils.logging import get_logger
from utils.weather_sat import WEATHER_SATELLITES
logger = get_logger('intercept.weather_sat_predict')
def predict_passes(
lat: float,
lon: float,
hours: int = 24,
min_elevation: float = 15.0,
include_trajectory: bool = False,
include_ground_track: bool = False,
) -> list[dict[str, Any]]:
"""Predict upcoming weather satellite passes for an observer location.
Args:
lat: Observer latitude (-90 to 90)
lon: Observer longitude (-180 to 180)
hours: Hours ahead to predict (1-72)
min_elevation: Minimum max elevation in degrees (0-90)
include_trajectory: Include az/el trajectory points (30 points)
include_ground_track: Include lat/lon ground track points (60 points)
Returns:
List of pass dicts sorted by start time.
Raises:
ImportError: If skyfield is not installed.
"""
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from data.satellites import TLE_SATELLITES
# Use live TLE cache from satellite module if available (refreshed from CelesTrak)
tle_source = TLE_SATELLITES
try:
from routes.satellite import _tle_cache
if _tle_cache:
tle_source = _tle_cache
except ImportError:
pass
ts = load.timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
all_passes: list[dict[str, Any]] = []
for sat_key, sat_info in WEATHER_SATELLITES.items():
if not sat_info['active']:
continue
tle_data = tle_source.get(sat_info['tle_key'])
if not tle_data:
continue
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
def above_horizon(t, _sat=satellite):
diff = _sat - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1 / 720
try:
times, events = find_discrete(t0, t1, above_horizon)
except Exception:
continue
i = 0
while i < len(times):
if i < len(events) and events[i]: # Rising
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)):
if not events[j]: # Setting
set_time = times[j]
i = j
break
else:
i += 1
continue
if set_time is None:
i += 1
continue
duration_seconds = (
set_time.utc_datetime() - rise_time.utc_datetime()
).total_seconds()
duration_minutes = round(duration_seconds / 60, 1)
# Calculate max elevation and trajectory
max_el = 0.0
max_el_az = 0.0
trajectory: list[dict[str, float]] = []
num_traj_points = 30
for k in range(num_traj_points):
frac = k / (num_traj_points - 1)
t_point = ts.utc(
rise_time.utc_datetime()
+ datetime.timedelta(seconds=duration_seconds * frac)
)
diff = satellite - observer
topocentric = diff.at(t_point)
alt, az, _ = topocentric.altaz()
if alt.degrees > max_el:
max_el = alt.degrees
max_el_az = az.degrees
if include_trajectory:
trajectory.append({
'el': float(max(0, alt.degrees)),
'az': float(az.degrees),
})
if max_el < min_elevation:
i += 1
continue
# Rise/set azimuths
rise_topo = (satellite - observer).at(rise_time)
_, rise_az, _ = rise_topo.altaz()
set_topo = (satellite - observer).at(set_time)
_, set_az, _ = set_topo.altaz()
pass_data: dict[str, Any] = {
'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M')}",
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'endTimeISO': set_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1),
'setAz': round(set_az.degrees, 1),
'duration': duration_minutes,
'quality': (
'excellent' if max_el >= 60
else 'good' if max_el >= 30
else 'fair'
),
}
if include_trajectory:
pass_data['trajectory'] = trajectory
if include_ground_track:
ground_track: list[dict[str, float]] = []
for k in range(60):
frac = k / 59
t_point = ts.utc(
rise_time.utc_datetime()
+ datetime.timedelta(seconds=duration_seconds * frac)
)
geocentric = satellite.at(t_point)
subpoint = wgs84.subpoint(geocentric)
ground_track.append({
'lat': float(subpoint.latitude.degrees),
'lon': float(subpoint.longitude.degrees),
})
pass_data['groundTrack'] = ground_track
all_passes.append(pass_data)
i += 1
all_passes.sort(key=lambda p: p['startTimeISO'])
return all_passes
+396
View File
@@ -0,0 +1,396 @@
"""Weather satellite auto-scheduler.
Automatically captures satellite passes based on predicted pass times.
Uses threading.Timer for scheduling no external dependencies required.
"""
from __future__ import annotations
import threading
import time
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Callable
from utils.logging import get_logger
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
logger = get_logger('intercept.weather_sat_scheduler')
# Import config defaults
try:
from config import (
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
)
except ImportError:
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
class ScheduledPass:
"""A pass scheduled for automatic capture."""
def __init__(self, pass_data: dict[str, Any]):
self.id: str = pass_data.get('id', str(uuid.uuid4())[:8])
self.satellite: str = pass_data['satellite']
self.name: str = pass_data['name']
self.frequency: float = pass_data['frequency']
self.mode: str = pass_data['mode']
self.start_time: str = pass_data['startTimeISO']
self.end_time: str = pass_data['endTimeISO']
self.max_el: float = pass_data['maxEl']
self.duration: float = pass_data['duration']
self.quality: str = pass_data['quality']
self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
self.skipped: bool = False
self._timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None
@property
def start_dt(self) -> datetime:
dt = datetime.fromisoformat(self.start_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
@property
def end_dt(self) -> datetime:
dt = datetime.fromisoformat(self.end_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_dict(self) -> dict[str, Any]:
return {
'id': self.id,
'satellite': self.satellite,
'name': self.name,
'frequency': self.frequency,
'mode': self.mode,
'startTimeISO': self.start_time,
'endTimeISO': self.end_time,
'maxEl': self.max_el,
'duration': self.duration,
'quality': self.quality,
'status': self.status,
'skipped': self.skipped,
}
class WeatherSatScheduler:
"""Auto-scheduler for weather satellite captures."""
def __init__(self):
self._enabled = False
self._lock = threading.Lock()
self._passes: list[ScheduledPass] = []
self._refresh_timer: threading.Timer | None = None
self._lat: float = 0.0
self._lon: float = 0.0
self._min_elevation: float = 15.0
self._device: int = 0
self._gain: float = 40.0
self._bias_t: bool = False
self._progress_callback: Callable[[CaptureProgress], None] | None = None
self._event_callback: Callable[[dict[str, Any]], None] | None = None
@property
def enabled(self) -> bool:
return self._enabled
def set_callbacks(
self,
progress_callback: Callable[[CaptureProgress], None],
event_callback: Callable[[dict[str, Any]], None],
) -> None:
"""Set callbacks for progress and scheduler events."""
self._progress_callback = progress_callback
self._event_callback = event_callback
def enable(
self,
lat: float,
lon: float,
min_elevation: float = 15.0,
device: int = 0,
gain: float = 40.0,
bias_t: bool = False,
) -> dict[str, Any]:
"""Enable auto-scheduling.
Args:
lat: Observer latitude
lon: Observer longitude
min_elevation: Minimum pass elevation to capture
device: RTL-SDR device index
gain: SDR gain in dB
bias_t: Enable bias-T
Returns:
Status dict with scheduled passes.
"""
with self._lock:
self._lat = lat
self._lon = lon
self._min_elevation = min_elevation
self._device = device
self._gain = gain
self._bias_t = bias_t
self._enabled = True
self._refresh_passes()
return self.get_status()
def disable(self) -> dict[str, Any]:
"""Disable auto-scheduling and cancel all timers."""
with self._lock:
self._enabled = False
# Cancel refresh timer
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = None
# Cancel all pass timers
for p in self._passes:
if p._timer:
p._timer.cancel()
p._timer = None
if p._stop_timer:
p._stop_timer.cancel()
p._stop_timer = None
self._passes.clear()
logger.info("Weather satellite auto-scheduler disabled")
return {'status': 'disabled'}
def skip_pass(self, pass_id: str) -> bool:
"""Manually skip a scheduled pass."""
with self._lock:
for p in self._passes:
if p.id == pass_id and p.status == 'scheduled':
p.skipped = True
p.status = 'skipped'
if p._timer:
p._timer.cancel()
p._timer = None
logger.info(f"Skipped pass: {p.satellite} at {p.start_time}")
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': p.to_dict(),
'reason': 'manual',
})
return True
return False
def get_status(self) -> dict[str, Any]:
"""Get current scheduler status."""
with self._lock:
return {
'enabled': self._enabled,
'observer': {'latitude': self._lat, 'longitude': self._lon},
'device': self._device,
'gain': self._gain,
'bias_t': self._bias_t,
'min_elevation': self._min_elevation,
'scheduled_count': sum(
1 for p in self._passes if p.status == 'scheduled'
),
'total_passes': len(self._passes),
}
def get_passes(self) -> list[dict[str, Any]]:
"""Get list of scheduled passes."""
with self._lock:
return [p.to_dict() for p in self._passes]
def _refresh_passes(self) -> None:
"""Recompute passes and schedule timers."""
if not self._enabled:
return
try:
from utils.weather_sat_predict import predict_passes
passes = predict_passes(
lat=self._lat,
lon=self._lon,
hours=24,
min_elevation=self._min_elevation,
)
except Exception as e:
logger.error(f"Failed to predict passes for scheduler: {e}")
passes = []
with self._lock:
# Cancel existing timers
for p in self._passes:
if p._timer:
p._timer.cancel()
if p._stop_timer:
p._stop_timer.cancel()
# Keep completed/skipped for history, replace scheduled
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
self._passes = history
now = datetime.now(timezone.utc)
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
for pass_data in passes:
sp = ScheduledPass(pass_data)
# Skip passes that already started
if sp.start_dt - timedelta(seconds=buffer) <= now:
continue
# Check if already in history
if any(h.id == sp.id for h in history):
continue
# Schedule capture timer
delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds()
if delay > 0:
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
sp._timer.daemon = True
sp._timer.start()
self._passes.append(sp)
logger.info(
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
f"passes scheduled"
)
# Schedule next refresh
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = threading.Timer(
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES * 60,
self._refresh_passes,
)
self._refresh_timer.daemon = True
self._refresh_timer.start()
def _execute_capture(self, sp: ScheduledPass) -> None:
"""Execute capture for a scheduled pass."""
if not self._enabled or sp.skipped:
return
decoder = get_weather_sat_decoder()
if decoder.is_running:
logger.info(f"SDR busy, skipping scheduled pass: {sp.satellite}")
sp.status = 'skipped'
sp.skipped = True
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'sdr_busy',
})
return
# Claim SDR device
try:
import app as app_module
error = app_module.claim_sdr_device(self._device, 'weather_sat')
if error:
logger.info(f"SDR device busy, skipping: {sp.satellite} - {error}")
sp.status = 'skipped'
sp.skipped = True
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'device_busy',
})
return
except ImportError:
pass
sp.status = 'capturing'
# Set up callbacks
if self._progress_callback:
decoder.set_callback(self._progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(self._device)
except ImportError:
pass
decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device))
success = decoder.start(
satellite=sp.satellite,
device_index=self._device,
gain=self._gain,
bias_t=self._bias_t,
)
if success:
logger.info(f"Auto-scheduler started capture: {sp.satellite}")
self._emit_event({
'type': 'schedule_capture_start',
'pass': sp.to_dict(),
})
# Schedule stop timer at pass end + buffer
now = datetime.now(timezone.utc)
stop_delay = (sp.end_dt + timedelta(seconds=WEATHER_SAT_CAPTURE_BUFFER_SECONDS) - now).total_seconds()
if stop_delay > 0:
sp._stop_timer = threading.Timer(stop_delay, self._stop_capture, args=[sp])
sp._stop_timer.daemon = True
sp._stop_timer.start()
else:
sp.status = 'skipped'
_release_device()
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'start_failed',
})
def _stop_capture(self, sp: ScheduledPass) -> None:
"""Stop capture at pass end."""
decoder = get_weather_sat_decoder()
if decoder.is_running:
decoder.stop()
logger.info(f"Auto-scheduler stopped capture: {sp.satellite}")
def _on_capture_complete(self, sp: ScheduledPass, release_fn: Callable) -> None:
"""Handle capture completion."""
sp.status = 'complete'
release_fn()
self._emit_event({
'type': 'schedule_capture_complete',
'pass': sp.to_dict(),
})
def _emit_event(self, event: dict[str, Any]) -> None:
"""Emit scheduler event to callback."""
if self._event_callback:
try:
self._event_callback(event)
except Exception as e:
logger.error(f"Error in scheduler event callback: {e}")
# Singleton
_scheduler: WeatherSatScheduler | None = None
_scheduler_lock = threading.Lock()
def get_weather_sat_scheduler() -> WeatherSatScheduler:
"""Get or create the global weather satellite scheduler instance."""
global _scheduler
if _scheduler is None:
with _scheduler_lock:
if _scheduler is None:
_scheduler = WeatherSatScheduler()
return _scheduler