Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec19d4b55e |
@@ -1,42 +0,0 @@
|
|||||||
## Workflow Orchestration
|
|
||||||
### 1. Plan Node Default
|
|
||||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
|
||||||
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
|
|
||||||
- Use plan mode for verification steps, not just building
|
|
||||||
- Write detailed specs upfront to reduce ambiguity
|
|
||||||
### 2. Subagent Strategy
|
|
||||||
- Use subagents liberally to keep main context window clean
|
|
||||||
- Offload research, exploration, and parallel analysis to subagents
|
|
||||||
- For complex problems, throw more compute at it via subagents
|
|
||||||
- One tack per subagent for focused execution
|
|
||||||
### 3. Self-Improvement Loop
|
|
||||||
- After ANY correction from the user: update 'tasks/lessons.md" with the pattern
|
|
||||||
- Write rules for yourself that prevent the same mistake
|
|
||||||
- Ruthlessly iterate on these lessons until mistake rate drops
|
|
||||||
- Review lessons at session start for relevant project
|
|
||||||
### 4. Verification Before Done
|
|
||||||
- Never mark a task complete without proving it works
|
|
||||||
- Diff behavior between main and your changes when relevant
|
|
||||||
- Ask yourself: "Would a staff engineer approve this?"
|
|
||||||
- Run tests, check logs, demonstrate correctness
|
|
||||||
### 5. Demand Elegance (Balanced)
|
|
||||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
|
||||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
|
||||||
- Skip this for simple, obvious fixes - don't over-engineer
|
|
||||||
-Challenge your own work before presenting it
|
|
||||||
### 6. Autonomous Bug Fizing
|
|
||||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
|
||||||
- Point at logs, errors, failing tests - then resolve them
|
|
||||||
- Zero context switching required from the user
|
|
||||||
- Go fix failing CI tests without being told how
|
|
||||||
## Task Management
|
|
||||||
1. **Plan First**: Write plan to "tasks/todo.md" with checkable items
|
|
||||||
2. **Verify Plan**: Check in before starting implementation
|
|
||||||
3. **Track Progress**: Mark items complete as you go
|
|
||||||
4. **Explain Changes**: High-level summary at each step
|
|
||||||
5. **Document Results**: Add review section to 'tasks/todo.md"
|
|
||||||
6. **Capture Lessons**: Update 'tasks/lessons.md' after corrections
|
|
||||||
## Core Principles
|
|
||||||
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
|
|
||||||
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
|
|
||||||
- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
# Git & CI
|
# Git
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.github
|
|
||||||
.claude
|
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__
|
__pycache__
|
||||||
@@ -31,23 +29,6 @@ tests/
|
|||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache
|
|
||||||
.DS_Store
|
|
||||||
tasks/
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
*.md
|
|
||||||
|
|
||||||
# Runtime data (mounted as volume)
|
|
||||||
instance/
|
|
||||||
|
|
||||||
# data/ is a Python package — only exclude non-code files
|
|
||||||
data/*.json
|
|
||||||
data/*.csv
|
|
||||||
data/*.db
|
|
||||||
|
|
||||||
# Build scripts
|
|
||||||
build-multiarch.sh
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,39 +1,2 @@
|
|||||||
# =============================================================================
|
# Uncomment and set to use external storage for ADS-B history
|
||||||
# INTERCEPT CONTROLLER (.env)
|
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||||
# =============================================================================
|
|
||||||
# Copy to .env and edit for your setup
|
|
||||||
|
|
||||||
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
|
|
||||||
TZ=UTC
|
|
||||||
|
|
||||||
# Flask secret key (auto-generated if not set)
|
|
||||||
# INTERCEPT_SECRET_KEY=your-secret-key-here
|
|
||||||
|
|
||||||
# Admin credentials (password auto-generated on first run if not set)
|
|
||||||
# INTERCEPT_ADMIN_USERNAME=admin
|
|
||||||
# INTERCEPT_ADMIN_PASSWORD=your-password-here
|
|
||||||
|
|
||||||
# Postgres password (default: intercept)
|
|
||||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
|
||||||
|
|
||||||
# Auto-start ADS-B when dashboard loads
|
|
||||||
INTERCEPT_ADSB_AUTO_START=false
|
|
||||||
|
|
||||||
# Share observer location across all modules
|
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=true
|
|
||||||
|
|
||||||
# Observer coordinates (uncomment and set to skip GPS prompt)
|
|
||||||
# INTERCEPT_DEFAULT_LAT=40.7128
|
|
||||||
# INTERCEPT_DEFAULT_LON=-74.0060
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# AGENT SETTINGS (for docker-compose.agent.yml on remote Pis)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Agent identity
|
|
||||||
AGENT_NAME=sdr-agent-1
|
|
||||||
AGENT_PORT=8020
|
|
||||||
|
|
||||||
# Controller connection (IP of the machine running docker-compose.yml)
|
|
||||||
CONTROLLER_URL=http://192.168.1.100:5050
|
|
||||||
AGENT_API_KEY=changeme
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Force LF line endings for files that must run on Linux (Docker)
|
|
||||||
*.sh text eol=lf
|
|
||||||
Dockerfile text eol=lf
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
- run: pip install -r requirements-dev.txt
|
|
||||||
- run: ruff check .
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
- run: pip install -r requirements-dev.txt
|
|
||||||
- name: Run tests
|
|
||||||
run: pytest --tb=short -q
|
|
||||||
continue-on-error: true
|
|
||||||
@@ -18,6 +18,10 @@ pager_messages.log
|
|||||||
downloads/
|
downloads/
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
downloads/
|
||||||
|
pgdata/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -51,19 +55,7 @@ intercept_agent_*.cfg
|
|||||||
/tmp/
|
/tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
|
||||||
data/weather_sat/
|
|
||||||
|
|
||||||
# Radiosonde runtime data (station config, logs)
|
|
||||||
data/radiosonde/
|
|
||||||
|
|
||||||
# SDR capture files (large IQ recordings)
|
|
||||||
data/subghz/captures/
|
|
||||||
|
|
||||||
# Env files
|
# Env files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Local utility scripts
|
|
||||||
reset-sdr.*
|
|
||||||
|
|||||||
@@ -2,262 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
## [2.26.8] - 2026-03-14
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **acarsdec build failure on macOS** — `HOST_NAME_MAX` is Linux-specific (`<limits.h>`) and undefined on macOS, causing 3 compile errors in `acarsdec.c`. Now patched with `#define HOST_NAME_MAX 255` before building. Also fixed deprecated `-Ofast` flag warning on all macOS architectures (was only patched for arm64). (#187)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.7] - 2026-03-14
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Health check SDR detection on macOS** — `timeout` (GNU coreutils) is not available on macOS, causing `rtl_test` to silently fail and report "No RTL-SDR device found" even when one is connected. Now tries `timeout`, then `gtimeout` (Homebrew coreutils), then falls back to a background process with manual kill. (#188)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.6] - 2026-03-14
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Oversized branded 'i' logo on dashboards** — `.logo span { display: inline }` in dashboard CSS had higher specificity (0,1,1) than `.brand-i { display: inline-block }` (0,1,0), forcing the branded "i" SVG to render as inline which ignores width/height. Added `.logo .brand-i` selector (0,2,0) to retain `inline-block` display. (#189)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.5] - 2026-03-14
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Database errors crash entire UI** — `get_setting()` now catches `sqlite3.OperationalError` and returns the default value instead of propagating the exception. Previously, if the database was inaccessible (e.g. root-owned `instance/` directory from running with `sudo`), the `inject_offline_settings` context processor would crash every page render with a 500 Internal Server Error. (#190)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.4] - 2026-03-14
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Environment Configurator crash** — `read_env_var()` crashed with "Setup failed at line 2333" when `.env` existed but didn't contain the variable being looked up. `grep` returned exit code 1 (no match), which `pipefail` propagated and `set -e` turned into a fatal error. Fixed by appending `|| true` to the pipeline. (#191)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.3] - 2026-03-13
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **SatDump AVX2 crash** — SatDump now compiles with `-march=x86-64` on x86_64 platforms (Docker and `setup.sh`), preventing "Illegal instruction" crashes on CPUs without AVX2. SIMD plugins still use runtime detection for acceleration on capable hardware. (#185)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.2] - 2026-03-13
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Docker startup crash** — `.dockerignore` excluded the entire `data/` directory, which is now a Python package (`data.oui`, `data.patterns`, `data.satellites`). Caused `ModuleNotFoundError: No module named 'data.oui'` on container startup. Fixed by only excluding non-code files from `data/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.1] - 2026-03-13
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Default admin credentials** — Default `ADMIN_PASSWORD` changed from empty string to `admin`, matching the README documentation (`admin:admin`)
|
|
||||||
- **Config credential sync** — Admin password changes in `config.py` or via `INTERCEPT_ADMIN_PASSWORD` env var now sync to the database on restart, without needing to delete the DB
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.26.0] - 2026-03-13
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **SSE fanout crash** - `_run_fanout` daemon thread no longer crashes with `AttributeError: 'NoneType' object has no attribute 'get'` when source queue becomes None during interpreter shutdown
|
|
||||||
- **Branded logo FOUC** - Added inline `width`/`height` to branded "i" SVG elements across 10 templates to prevent oversized rendering before CSS loads; refresh no longer needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.25.0] - 2026-03-12
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **SSEManager** - Centralized SSE connection management with exponential backoff reconnection and visual connection status indicator
|
|
||||||
- **Loading button states** - `withLoadingButton()` utility for async action buttons across all modes
|
|
||||||
- **Actionable error reporting** - `reportActionableError()` added to 5 mode JS files for user-friendly error messages
|
|
||||||
- **Destructive action confirmation modals** - Custom modal system replacing 25 native `confirm()` calls
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Accessibility improvements** - aria-labels on interactive elements, form label associations, keyboard-navigable lists
|
|
||||||
- **CSS variable adoption** - Replaced hardcoded hex colors with CSS custom properties across 16+ files
|
|
||||||
- **Inline style extraction** - `classList.toggle()` replaces inline `display` manipulation throughout codebase
|
|
||||||
- **Merged `global-nav.css` into `layout.css`** - Consolidated navigation styles
|
|
||||||
- **Reduced `!important` usage** - Responsive.css `!important` count reduced from 71 to 8
|
|
||||||
- **Standardized breakpoints** - Unified to 480/768/1024/1280px across all responsive styles
|
|
||||||
- **Mobile UX polish** - Improved touch targets, code overflow handling, and responsive layouts
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Deep-linked mode scripts now wait for body parse before executing, preventing initialization failures
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.24.0] - 2026-03-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **WiFi Locate Mode** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones. Hand-off from WiFi detail drawer, environment presets (Free Space/Outdoor/Indoor), and signal-lost detection.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Mobile navigation bar reorganized into labeled groups (SIG, TRK, SPC, WIFI, INTEL, SYS) for better usability
|
|
||||||
- flask-limiter made optional — rate limiting degrades gracefully if package is missing
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Radiosonde setup missing `semver` Python dependency — `setup.sh` now explicitly installs it alongside `requirements.txt`
|
|
||||||
|
|
||||||
## [2.23.0] - 2026-02-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Radiosonde Weather Balloon Tracking** - 400-406 MHz reception via radiosonde_auto_rx with telemetry, map, and station distance tracking
|
|
||||||
- **CW/Morse Code Decoder** - Custom Goertzel tone detection with OOK/AM envelope detection mode for ISM bands
|
|
||||||
- **WeFax (Weather Fax) Decoder** - HF weather fax reception with auto-scheduler, broadcast timeline, and image gallery
|
|
||||||
- **System Health Monitoring** - Telemetry dashboard with process monitoring and system metrics
|
|
||||||
- **HTTPS Support** - TLS via `INTERCEPT_HTTPS` configuration
|
|
||||||
- **ADS-B Voice Alerts** - Text-to-speech notifications for military and emergency aircraft detections
|
|
||||||
- **HackRF TSCM RF Scan** - HackRF support added to TSCM counter-surveillance RF sweep
|
|
||||||
- **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder
|
|
||||||
- **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations
|
|
||||||
- **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations
|
|
||||||
- **Production Server** - `start.sh` with gunicorn + gevent for concurrent SSE/WebSocket handling — eliminates multi-client page load delays
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency
|
|
||||||
- GPS mode upgraded to textured 3D globe visualization
|
|
||||||
- Destroy lifecycle added to all mode modules to prevent resource leaks
|
|
||||||
- Docker container now uses gunicorn + gevent by default via `start.sh`
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- ADS-B device release leak and startup performance regression
|
|
||||||
- ADS-B probe incorrectly treating "No devices found" as success
|
|
||||||
- USB claim race condition after SDR probe
|
|
||||||
- SDR device registry collision when multiple SDR types present
|
|
||||||
- APRS 15-minute startup delay caused by pipe buffering
|
|
||||||
- APRS map centering at [0,0] when GPS unavailable
|
|
||||||
- DSC decoder ITU-R M.493 compliance issues
|
|
||||||
- Weather satellite 0dB SNR — increased sample rate for Meteor LRPT
|
|
||||||
- SSE fanout backlog causing delayed updates across all modes
|
|
||||||
- SSE reconnect packet loss during client reconnection
|
|
||||||
- Waterfall monitor tuning race conditions
|
|
||||||
- Mode FOUC (flash of unstyled content) on initial navigation
|
|
||||||
- Various Morse decoder stability and lifecycle fixes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.22.3] - 2026-02-23
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets
|
|
||||||
- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions
|
|
||||||
- Waterfall monitor audio took minutes to start — `_waitForPlayback` now only reports success on actual audio playback (`playing`/`timeupdate`), not from the WAV header alone (`loadeddata`/`canplay`)
|
|
||||||
- Waterfall monitor could not be stopped — `stopMonitor()` now pauses audio and updates the UI immediately instead of waiting for the backend stop request (which blocked for 1+ seconds during SDR process cleanup)
|
|
||||||
- Stopping the waterfall no longer shows a stale "WebSocket closed before ready" message — the `onclose` handler now detects intentional closes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.22.1] - 2026-02-23
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512)
|
|
||||||
- Apple touch icon updated to PNG for iOS Safari compatibility
|
|
||||||
- Service worker cache bumped to bust stale cached assets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.22.0] - 2026-02-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling
|
|
||||||
- **Voice Alerts** - Configurable text-to-speech event notifications across modes
|
|
||||||
- **Signal Fingerprinting** - RF device identification and pattern analysis mode
|
|
||||||
- **SignalID** - Automatic signal classification via SigIDWiki API integration
|
|
||||||
- **PWA Support** - Installable web app with service worker caching and manifest
|
|
||||||
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
|
||||||
- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames
|
|
||||||
- **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls
|
|
||||||
- App icon (SVG) for PWA and browser tab
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **WebSDR overhaul** - Improved receiver management, audio streaming, and UI
|
|
||||||
- **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown
|
|
||||||
- **Mode transitions** - Smoother navigation with performance instrumentation
|
|
||||||
- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing
|
|
||||||
- **Listening Post** - Refactored with cross-module frequency routing
|
|
||||||
- **SSTV decoder** - State machine improvements and partial image streaming
|
|
||||||
- Analytics mode removed; per-mode analytics panels integrated into existing dashboards
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- ADS-B SSE multi-client fanout stability and update flush timing
|
|
||||||
- WiFi scanner robustness and monitor mode teardown reliability
|
|
||||||
- Agent client reliability improvements for remote sensor nodes
|
|
||||||
- SSTV VIS detector state reporting in signal monitor diagnostics
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages
|
|
||||||
- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file
|
|
||||||
- Fixed tool name `rtl_amr` → `rtlamr` throughout all docs
|
|
||||||
- Fixed incorrect entry point examples (`python app.py` → `sudo -E venv/bin/python intercept.py`)
|
|
||||||
- Removed duplicate AIS Vessel Tracking section from FEATURES.md
|
|
||||||
- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed
|
|
||||||
- Added ACARS and VDL2 mode descriptions to in-app help modal
|
|
||||||
- GitHub Pages site: corrected Docker command, license, and tool name references
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.21.1] - 2026-02-20
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- BT Locate map first-load rendering race that could cause blank/late map initialization
|
|
||||||
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
|
|
||||||
- BT Locate trail restore startup latency by batching historical GPS point rendering
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.21.0] - 2026-02-20
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Analytics panels for operational insights and temporal pattern analysis
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Global map theme refresh with improved contrast and cross-dashboard consistency
|
|
||||||
- Cross-app UX refinements for accessibility, mode consistency, and render performance
|
|
||||||
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Weather satellite auto-scheduler and Mercator tracking reliability issues
|
|
||||||
- Bluetooth/WiFi runtime health issues affecting scanner continuity
|
|
||||||
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.15.0] - 2026-02-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
|
||||||
- Click-to-tune, zoom controls, and auto-scaling quantization
|
|
||||||
- Shared waterfall UI across SDR modes with function bar controls
|
|
||||||
- WebSocket frame serialization and connection reuse
|
|
||||||
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
|
|
||||||
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
|
|
||||||
- Real-time decode progress with partial image streaming
|
|
||||||
- VIS detector state in signal monitor diagnostics
|
|
||||||
- Image gallery with delete and download functionality
|
|
||||||
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
|
||||||
- **SSTV Image Gallery** - Delete and download decoded images
|
|
||||||
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- DMR dsd-fme protocol flags, device label, and tuning controls
|
|
||||||
- DMR frontend/backend state desync causing 409 on start
|
|
||||||
- Digital voice decoder producing no output due to wrong dsd-fme flags
|
|
||||||
- SDR device lock-up from unreleased device registry on process crash
|
|
||||||
- APRS crash on large station count and station list overflow
|
|
||||||
- Settings modal overflowing viewport on smaller screens
|
|
||||||
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
|
|
||||||
- PD120 SSTV decode hang and false leader tone detection
|
|
||||||
- WebSocket waterfall blocked by login redirect
|
|
||||||
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- GSM Spy functionality removed for legal compliance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [2.14.0] - 2026-02-06
|
## [2.14.0] - 2026-02-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,46 +4,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
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.
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
### Docker (Primary)
|
### Setup and Running
|
||||||
```bash
|
```bash
|
||||||
# Build and run (basic profile)
|
# Initial setup (installs dependencies and configures SDR tools)
|
||||||
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
|
|
||||||
# First-time setup (interactive wizard with install profiles)
|
|
||||||
./setup.sh
|
./setup.sh
|
||||||
|
|
||||||
# Or headless full install
|
# Run the application (requires sudo for SDR/network access)
|
||||||
./setup.sh --non-interactive
|
|
||||||
|
|
||||||
# Or install specific profiles
|
|
||||||
./setup.sh --profile=core,weather
|
|
||||||
|
|
||||||
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
|
|
||||||
sudo ./start.sh
|
|
||||||
|
|
||||||
# Or for quick local dev (Flask dev server)
|
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo -E venv/bin/python intercept.py
|
||||||
|
|
||||||
# Other setup utilities
|
# Or activate venv first
|
||||||
./setup.sh --health-check # Verify installation
|
source venv/bin/activate
|
||||||
./setup.sh --postgres-setup # Set up ADS-B history database
|
sudo -E python intercept.py
|
||||||
./setup.sh --menu # Force interactive menu
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@@ -79,10 +54,8 @@ mypy .
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Entry Points
|
### Entry Points
|
||||||
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
|
- `intercept.py` - Main entry point script
|
||||||
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
|
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
||||||
- `intercept.py` - Direct Flask dev server entry point (quick local development)
|
|
||||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
|
|
||||||
|
|
||||||
### Route Blueprints (routes/)
|
### Route Blueprints (routes/)
|
||||||
Each signal type has its own Flask blueprint:
|
Each signal type has its own Flask blueprint:
|
||||||
@@ -93,12 +66,8 @@ Each signal type has its own Flask blueprint:
|
|||||||
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||||
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||||
- `satellite.py` - Pass prediction using TLE data
|
- `satellite.py` - Pass prediction using TLE data
|
||||||
- `sstv.py` - ISS SSTV image decoding via slowrx
|
|
||||||
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
|
||||||
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
|
||||||
- `aprs.py` - Amateur packet radio via direwolf
|
- `aprs.py` - Amateur packet radio via direwolf
|
||||||
- `rtlamr.py` - Utility meter reading
|
- `rtlamr.py` - Utility meter reading
|
||||||
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
|
||||||
|
|
||||||
### Core Utilities (utils/)
|
### Core Utilities (utils/)
|
||||||
|
|
||||||
@@ -122,18 +91,9 @@ Each signal type has its own Flask blueprint:
|
|||||||
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||||
- `channel_analyzer.py` - Frequency band analysis
|
- `channel_analyzer.py` - Frequency band analysis
|
||||||
|
|
||||||
**Weather Satellite** (`utils/weather_sat.py`):
|
|
||||||
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
|
||||||
- Subprocess management with stdout parsing, image watcher via rglob
|
|
||||||
- Pass prediction using skyfield TLE data
|
|
||||||
|
|
||||||
**SSTV Decoder** (`utils/sstv.py`):
|
|
||||||
- ISS SSTV reception via slowrx with Doppler tracking
|
|
||||||
- Singleton pattern, image gallery with timestamped filenames
|
|
||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
|
**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.
|
||||||
|
|
||||||
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||||
|
|
||||||
@@ -152,25 +112,9 @@ Each signal type has its own Flask blueprint:
|
|||||||
| acarsdec | ACARS messages | Output parsing |
|
| acarsdec | ACARS messages | Output parsing |
|
||||||
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||||
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||||
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
|
||||||
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
|
||||||
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
|
||||||
| direwolf | APRS | TNC modem for packet radio |
|
|
||||||
|
|
||||||
### Frontend Structure
|
|
||||||
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
|
||||||
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
|
||||||
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
|
||||||
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
|
||||||
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
|
||||||
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
|
||||||
- Data persisted via `./data:/app/data` volume mount
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
- `config.py` - Environment variable support with `INTERCEPT_` prefix
|
||||||
- Database: SQLite in `instance/` directory for settings, baselines, history
|
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|||||||
@@ -1,197 +1,6 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker container for running the web interface
|
# Docker container for running the web interface
|
||||||
# Multi-stage build: builder compiles tools, runtime keeps only what's needed
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# Stage 1: Builder — compile all tools from source
|
|
||||||
###############################################################################
|
|
||||||
FROM python:3.11-slim AS builder
|
|
||||||
|
|
||||||
WORKDIR /tmp/build
|
|
||||||
|
|
||||||
# Install ALL build dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
git \
|
|
||||||
pkg-config \
|
|
||||||
cmake \
|
|
||||||
librtlsdr-dev \
|
|
||||||
libusb-1.0-0-dev \
|
|
||||||
libncurses-dev \
|
|
||||||
libsndfile1-dev \
|
|
||||||
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 \
|
|
||||||
libzmq3-dev \
|
|
||||||
libpulse-dev \
|
|
||||||
libfftw3-bin \
|
|
||||||
liblapack-dev \
|
|
||||||
libglib2.0-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create staging directory for all built artifacts
|
|
||||||
RUN mkdir -p /staging/usr/bin /staging/usr/local/bin /staging/usr/local/lib /staging/opt
|
|
||||||
|
|
||||||
# Build dump1090
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
|
||||||
&& cd dump1090 \
|
|
||||||
&& sed -i 's/-Werror//g' Makefile \
|
|
||||||
&& make BLADERF=no RTLSDR=yes \
|
|
||||||
&& cp dump1090 /staging/usr/bin/dump1090-fa \
|
|
||||||
&& ln -s /usr/bin/dump1090-fa /staging/usr/bin/dump1090 \
|
|
||||||
&& rm -rf /tmp/dump1090
|
|
||||||
|
|
||||||
# Build AIS-catcher
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
|
||||||
&& cd AIS-catcher \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& cp AIS-catcher /staging/usr/bin/AIS-catcher \
|
|
||||||
&& rm -rf /tmp/AIS-catcher
|
|
||||||
|
|
||||||
# Build readsb
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
|
||||||
&& cd readsb \
|
|
||||||
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
|
||||||
&& cp readsb /staging/usr/bin/readsb \
|
|
||||||
&& rm -rf /tmp/readsb
|
|
||||||
|
|
||||||
# Build rx_tools
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone https://github.com/rxseger/rx_tools.git \
|
|
||||||
&& cd rx_tools \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& DESTDIR=/staging make install \
|
|
||||||
&& rm -rf /tmp/rx_tools
|
|
||||||
|
|
||||||
# Build acarsdec
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
|
||||||
&& cd acarsdec \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
|
||||||
&& make \
|
|
||||||
&& cp acarsdec /staging/usr/bin/acarsdec \
|
|
||||||
&& rm -rf /tmp/acarsdec
|
|
||||||
|
|
||||||
# Build libacars (required by dumpvdl2)
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
|
||||||
&& cd libacars \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& ldconfig \
|
|
||||||
&& cp -a /usr/local/lib/libacars* /staging/usr/local/lib/ \
|
|
||||||
&& rm -rf /tmp/libacars
|
|
||||||
|
|
||||||
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
|
||||||
&& cd dumpvdl2 \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& cp src/dumpvdl2 /staging/usr/bin/dumpvdl2 \
|
|
||||||
&& rm -rf /tmp/dumpvdl2
|
|
||||||
|
|
||||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone https://github.com/windytan/slowrx.git \
|
|
||||||
&& cd slowrx \
|
|
||||||
&& git checkout ca6d7012 \
|
|
||||||
&& make \
|
|
||||||
&& install -m 0755 slowrx /staging/usr/local/bin/slowrx \
|
|
||||||
&& rm -rf /tmp/slowrx
|
|
||||||
|
|
||||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
|
||||||
&& cd SatDump \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& ARCH_FLAGS=""; if [ "$(uname -m)" = "x86_64" ]; then ARCH_FLAGS="-march=x86-64"; fi \
|
|
||||||
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
|
|
||||||
-DCMAKE_C_FLAGS="$ARCH_FLAGS" \
|
|
||||||
-DCMAKE_CXX_FLAGS="$ARCH_FLAGS" .. \
|
|
||||||
&& 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 \
|
|
||||||
# Copy SatDump install artifacts to staging
|
|
||||||
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
|
|
||||||
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
|
|
||||||
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
|
|
||||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
|
|
||||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
|
|
||||||
&& rm -rf /tmp/SatDump
|
|
||||||
|
|
||||||
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
|
||||||
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
|
||||||
&& cd hackrf/host \
|
|
||||||
&& mkdir build && cd build \
|
|
||||||
&& cmake .. \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& ldconfig \
|
|
||||||
&& cp -a /usr/local/bin/hackrf_* /staging/usr/local/bin/ 2>/dev/null || true \
|
|
||||||
&& cp -a /usr/local/lib/libhackrf* /staging/usr/local/lib/ 2>/dev/null || true \
|
|
||||||
&& rm -rf /tmp/hackrf
|
|
||||||
|
|
||||||
# Install radiosonde_auto_rx (weather balloon decoder)
|
|
||||||
RUN cd /tmp \
|
|
||||||
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
|
|
||||||
&& cd radiosonde_auto_rx/auto_rx \
|
|
||||||
&& pip install --no-cache-dir -r requirements.txt semver \
|
|
||||||
&& bash build.sh \
|
|
||||||
&& mkdir -p /staging/opt/radiosonde_auto_rx/auto_rx \
|
|
||||||
&& cp -r . /staging/opt/radiosonde_auto_rx/auto_rx/ \
|
|
||||||
&& chmod +x /staging/opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
|
|
||||||
&& rm -rf /tmp/radiosonde_auto_rx
|
|
||||||
|
|
||||||
# Build rtlamr (utility meter decoder - requires Go)
|
|
||||||
RUN 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 /staging/usr/bin/rtlamr \
|
|
||||||
&& rm -rf /usr/local/go /tmp/gopath
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# Stage 2: Runtime — lean image with only runtime dependencies
|
|
||||||
###############################################################################
|
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
LABEL maintainer="INTERCEPT Project"
|
LABEL maintainer="INTERCEPT Project"
|
||||||
@@ -200,28 +9,18 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Pre-accept tshark non-root capture prompt for non-interactive install
|
# Install system dependencies for SDR tools
|
||||||
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
|
||||||
|
|
||||||
# Install ONLY runtime dependencies (no -dev packages, no build tools)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
rtl-sdr \
|
rtl-sdr \
|
||||||
|
librtlsdr-dev \
|
||||||
|
libusb-1.0-0-dev \
|
||||||
# 433MHz decoder
|
# 433MHz decoder
|
||||||
rtl-433 \
|
rtl-433 \
|
||||||
# Pager decoder
|
# Pager decoder
|
||||||
multimon-ng \
|
multimon-ng \
|
||||||
# Audio tools for Listening Post
|
# Audio tools for Listening Post
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
# SSTV decoder runtime libs
|
|
||||||
libsndfile1 \
|
|
||||||
# SatDump runtime libs (weather satellite decoding)
|
|
||||||
libpng16-16 \
|
|
||||||
libtiff6 \
|
|
||||||
libjemalloc2 \
|
|
||||||
libvolk-bin \
|
|
||||||
libnng1 \
|
|
||||||
libzstd1 \
|
|
||||||
# WiFi tools (aircrack-ng suite)
|
# WiFi tools (aircrack-ng suite)
|
||||||
aircrack-ng \
|
aircrack-ng \
|
||||||
iw \
|
iw \
|
||||||
@@ -230,8 +29,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
bluez \
|
bluez \
|
||||||
bluetooth \
|
bluetooth \
|
||||||
# GPS support
|
# GPS support
|
||||||
gpsd \
|
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
|
# Utilities
|
||||||
# APRS
|
# APRS
|
||||||
direwolf \
|
direwolf \
|
||||||
# WiFi Extra
|
# WiFi Extra
|
||||||
@@ -242,25 +41,120 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
soapysdr-module-rtlsdr \
|
soapysdr-module-rtlsdr \
|
||||||
soapysdr-module-hackrf \
|
soapysdr-module-hackrf \
|
||||||
soapysdr-module-lms7 \
|
soapysdr-module-lms7 \
|
||||||
soapysdr-module-airspy \
|
|
||||||
airspy \
|
|
||||||
limesuite \
|
limesuite \
|
||||||
|
hackrf \
|
||||||
# Utilities
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy compiled binaries and libraries from builder stage
|
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||||
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
build-essential \
|
||||||
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
git \
|
||||||
COPY --from=builder /staging/opt/ /opt/
|
pkg-config \
|
||||||
|
cmake \
|
||||||
# Copy radiosonde Python dependencies installed during builder stage
|
libncurses-dev \
|
||||||
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
|
libsndfile1-dev \
|
||||||
|
libsoapysdr-dev \
|
||||||
# Refresh shared library cache for custom-built libraries
|
libhackrf-dev \
|
||||||
RUN ldconfig
|
liblimesuite-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libzmq3-dev \
|
||||||
|
libpulse-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
liblapack-dev \
|
||||||
|
libcodec2-dev \
|
||||||
|
# Build dump1090
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
|
&& cd dump1090 \
|
||||||
|
&& sed -i 's/-Werror//g' Makefile \
|
||||||
|
&& make BLADERF=no RTLSDR=yes \
|
||||||
|
&& cp dump1090 /usr/bin/dump1090-fa \
|
||||||
|
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
||||||
|
&& rm -rf /tmp/dump1090 \
|
||||||
|
# Build AIS-catcher
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
||||||
|
&& cd AIS-catcher \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp AIS-catcher /usr/bin/AIS-catcher \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/AIS-catcher \
|
||||||
|
# Build readsb
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
||||||
|
&& cd readsb \
|
||||||
|
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
||||||
|
&& cp readsb /usr/bin/readsb \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/readsb \
|
||||||
|
# Build rx_tools
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/rxseger/rx_tools.git \
|
||||||
|
&& cd rx_tools \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/rx_tools \
|
||||||
|
# Build acarsdec
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||||
|
&& cd acarsdec \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. -Drtl=ON \
|
||||||
|
&& make \
|
||||||
|
&& cp acarsdec /usr/bin/acarsdec \
|
||||||
|
&& rm -rf /tmp/acarsdec \
|
||||||
|
# Build mbelib (required by DSD)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/lwvmobile/mbelib.git \
|
||||||
|
&& cd mbelib \
|
||||||
|
&& (git checkout ambe_tones || true) \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/mbelib \
|
||||||
|
# Build DSD-FME (Digital Speech Decoder for DMR/P25)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \
|
||||||
|
&& cd dsd-fme \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/dsd-fme \
|
||||||
|
# Cleanup build tools to reduce image size
|
||||||
|
&& apt-get remove -y \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
pkg-config \
|
||||||
|
cmake \
|
||||||
|
libncurses-dev \
|
||||||
|
libsndfile1-dev \
|
||||||
|
libsoapysdr-dev \
|
||||||
|
libhackrf-dev \
|
||||||
|
liblimesuite-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libzmq3-dev \
|
||||||
|
libpulse-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
liblapack-dev \
|
||||||
|
libcodec2-dev \
|
||||||
|
&& apt-get autoremove -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -269,15 +163,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Strip Windows CRLF from shell scripts (git autocrlf can re-introduce them)
|
|
||||||
RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} +
|
|
||||||
|
|
||||||
# Create data directory for persistence
|
# Create data directory for persistence
|
||||||
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
EXPOSE 5443
|
|
||||||
|
|
||||||
# Environment variables with defaults
|
# Environment variables with defaults
|
||||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||||
@@ -290,4 +180,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD curl -sf http://localhost:5050/health || exit 1
|
CMD curl -sf http://localhost:5050/health || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["/bin/bash", "start.sh"]
|
CMD ["python", "intercept.py"]
|
||||||
|
|||||||
@@ -1,200 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
Apache License
|
Copyright (c) 2025 smittix
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
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:
|
||||||
|
|
||||||
1. Definitions.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
the copyright owner that is granting the License.
|
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
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
SOFTWARE.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<p align="center">
|
# INTERCEPT
|
||||||
<img src="static/images/readme-banner.svg" alt="iNTERCEPT — Signal Intelligence Platform" width="100%">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||||
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
|
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
||||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -30,198 +28,58 @@ Support the developer of this open-source project
|
|||||||
|
|
||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
|
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
- **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
|
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
|
||||||
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
- **Listening Post** - Frequency scanner with audio monitoring
|
||||||
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
|
||||||
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
- **ISS SSTV** - Receive slow-scan TV from the International Space Station
|
||||||
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
|
||||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
- **Satellite Tracking** - Pass prediction using TLE data
|
||||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
|
||||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
|
||||||
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
|
||||||
- **WiFi Locate** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, and proximity audio
|
|
||||||
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
|
||||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||||
- **Meshtastic** - LoRa mesh network integration
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CW / Morse Decoder Notes
|
## Installation / Debian / Ubuntu / MacOS
|
||||||
|
|
||||||
Live backend:
|
```
|
||||||
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
|
||||||
|
|
||||||
Recommended baseline settings:
|
**1. Clone and run:**
|
||||||
- **Tone**: `700 Hz`
|
```bash
|
||||||
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
git clone https://github.com/smittix/intercept.git
|
||||||
- **Threshold Mode**: `Auto`
|
cd intercept
|
||||||
- **WPM Mode**: `Auto`
|
./setup.sh
|
||||||
|
sudo -E venv/bin/python intercept.py
|
||||||
|
```
|
||||||
|
|
||||||
Auto Tone Track behavior:
|
### Docker (Alternative)
|
||||||
- Continuously measures nearby tone energy around the configured CW pitch.
|
|
||||||
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
|
|
||||||
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
|
|
||||||
|
|
||||||
Troubleshooting (no decode / noisy decode):
|
|
||||||
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
|
|
||||||
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
|
|
||||||
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
|
|
||||||
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
|
|
||||||
- Use **Reset/Calibrate** after major frequency or band condition changes.
|
|
||||||
- Raise **Minimum Signal Gate** to suppress random noise keying.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation / Debian / Ubuntu / macOS
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh # Interactive menu (first run launches setup wizard)
|
docker compose up -d
|
||||||
sudo ./start.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On first run, `setup.sh` launches a **guided wizard** that detects your OS, lets you choose install profiles, sets up the Python environment, and optionally configures environment variables and PostgreSQL.
|
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||||
|
|
||||||
On subsequent runs, it opens an **interactive menu**:
|
|
||||||
|
|
||||||
```
|
|
||||||
INTERCEPT Setup Menu
|
|
||||||
════════════════════════════════════════
|
|
||||||
1) Install / Add Modules
|
|
||||||
2) System Health Check
|
|
||||||
3) Database Setup (ADS-B History)
|
|
||||||
4) Update Tools
|
|
||||||
5) Environment Configurator
|
|
||||||
6) Uninstall / Cleanup
|
|
||||||
7) View Status
|
|
||||||
0) Exit
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Production vs Dev server:** `start.sh` auto-detects gunicorn + gevent and runs a production server with cooperative greenlets — handles multiple SSE/WebSocket clients without blocking. Falls back to Flask dev server if gunicorn is not installed. For quick local development, you can still use `sudo -E venv/bin/python intercept.py` directly.
|
|
||||||
|
|
||||||
### Install Profiles
|
|
||||||
|
|
||||||
Choose what to install during the wizard or via menu option 1:
|
|
||||||
|
|
||||||
| # | Profile | Tools |
|
|
||||||
|---|---------|-------|
|
|
||||||
| 1 | Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
|
|
||||||
| 2 | Maritime & Radio | AIS-catcher, direwolf |
|
|
||||||
| 3 | Weather & Space | SatDump, radiosonde_auto_rx |
|
|
||||||
| 4 | RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
|
|
||||||
| 5 | Full SIGINT | All of the above |
|
|
||||||
| 6 | Custom | Per-tool checklist |
|
|
||||||
|
|
||||||
Multiple profiles can be combined (e.g. enter `1 3` for Core + Weather).
|
|
||||||
|
|
||||||
### CLI Flags
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./setup.sh --non-interactive # Headless full install (same as legacy behavior)
|
|
||||||
./setup.sh --profile=core,weather # Install specific profiles
|
|
||||||
./setup.sh --health-check # Check system health and exit
|
|
||||||
./setup.sh --postgres-setup # Run PostgreSQL setup and exit
|
|
||||||
./setup.sh --menu # Force interactive menu
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/smittix/intercept.git
|
|
||||||
cd intercept
|
|
||||||
docker compose --profile basic up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
> **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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
|
|
||||||
Use the **Environment Configurator** (menu option 5) to interactively set any `INTERCEPT_*` variable. Settings are saved to a `.env` file that `start.sh` sources automatically on startup.
|
|
||||||
|
|
||||||
You can also create or edit `.env` manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env (auto-loaded by start.sh)
|
|
||||||
INTERCEPT_PORT=5050
|
|
||||||
INTERCEPT_ADSB_AUTO_START=true
|
|
||||||
INTERCEPT_DEFAULT_LAT=51.5074
|
|
||||||
INTERCEPT_DEFAULT_LON=-0.1278
|
|
||||||
```
|
|
||||||
|
|
||||||
### ADS-B History (Optional)
|
### ADS-B History (Optional)
|
||||||
|
|
||||||
The ADS-B history feature persists aircraft messages to PostgreSQL for long-term analysis.
|
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||||
|
|
||||||
**Automated setup (local install):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./setup.sh --postgres-setup
|
|
||||||
# Or use menu option 3: Database Setup
|
|
||||||
```
|
|
||||||
|
|
||||||
This will install PostgreSQL if needed, create the database/user/tables, and write the connection settings to `.env`.
|
|
||||||
|
|
||||||
**Docker:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Start with ADS-B history and Postgres
|
||||||
docker compose --profile history up -d
|
docker compose --profile history up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Set the following environment variables (in `.env`):
|
Set the following environment variables (for example in a `.env` file):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
@@ -232,6 +90,30 @@ INTERCEPT_ADSB_DB_USER=intercept
|
|||||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Other ADS-B Settings
|
||||||
|
|
||||||
|
Set these as environment variables for either local installs or Docker:
|
||||||
|
|
||||||
|
| 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
|
||||||
|
```
|
||||||
|
|
||||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -240,22 +122,11 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
|||||||
|
|
||||||
Then open **/adsb/history** for the reporting dashboard.
|
Then open **/adsb/history** for the reporting dashboard.
|
||||||
|
|
||||||
### System Health Check
|
|
||||||
|
|
||||||
Verify your installation is complete and working:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./setup.sh --health-check
|
|
||||||
# Or use menu option 2
|
|
||||||
```
|
|
||||||
|
|
||||||
Checks installed tools, SDR devices, port availability, permissions, Python venv, `.env` configuration, and PostgreSQL connectivity.
|
|
||||||
|
|
||||||
### Open the Interface
|
### Open the Interface
|
||||||
|
|
||||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||||
|
|
||||||
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -313,7 +184,7 @@ This project was developed using AI as a coding partner, combining human directi
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache 2.0 License - see [LICENSE](LICENSE)
|
MIT License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
@@ -327,12 +198,8 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[dump1090](https://github.com/flightaware/dump1090) |
|
[dump1090](https://github.com/flightaware/dump1090) |
|
||||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
|
||||||
[rtlamr](https://github.com/bemasher/rtlamr) |
|
|
||||||
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
[SatDump](https://github.com/SatDump/SatDump) |
|
|
||||||
[Celestrak](https://celestrak.org/) |
|
[Celestrak](https://celestrak.org/) |
|
||||||
[Priyom.org](https://priyom.org/)
|
[Priyom.org](https://priyom.org/)
|
||||||
|
|
||||||
@@ -344,4 +211,3 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-02-22_17194a71",
|
"version": "2026-02-01_ba81b697",
|
||||||
"downloaded": "2026-02-27T10:41:04.872620Z"
|
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,8 @@ Flask application and shared state.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import site
|
|
||||||
import sys
|
import sys
|
||||||
|
import site
|
||||||
|
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
|
|
||||||
@@ -17,122 +17,48 @@ if not site.ENABLE_USER_SITE:
|
|||||||
if user_site and user_site not in sys.path:
|
if user_site and user_site not in sys.path:
|
||||||
sys.path.insert(0, user_site)
|
sys.path.insert(0, user_site)
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import platform
|
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
import platform
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from flask import (
|
from typing import Any
|
||||||
Flask,
|
|
||||||
Response,
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||||
flash,
|
|
||||||
jsonify,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
send_file,
|
|
||||||
send_from_directory,
|
|
||||||
session,
|
|
||||||
url_for,
|
|
||||||
)
|
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from config import CHANGELOG, DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED, VERSION
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
|
from utils.process import cleanup_stale_processes
|
||||||
|
from utils.sdr import SDRFactory
|
||||||
from utils.cleanup import DataStore, cleanup_manager
|
from utils.cleanup import DataStore, cleanup_manager
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
MAX_AIRCRAFT_AGE_SECONDS,
|
MAX_AIRCRAFT_AGE_SECONDS,
|
||||||
MAX_BT_DEVICE_AGE_SECONDS,
|
|
||||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
|
||||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
|
||||||
MAX_VESSEL_AGE_SECONDS,
|
|
||||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||||
|
MAX_BT_DEVICE_AGE_SECONDS,
|
||||||
|
MAX_VESSEL_AGE_SECONDS,
|
||||||
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||||
|
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
from utils.dependencies import check_all_dependencies, check_tool
|
import logging
|
||||||
from utils.process import cleanup_stale_dump1090, cleanup_stale_processes
|
|
||||||
from utils.sdr import SDRFactory
|
|
||||||
|
|
||||||
try:
|
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
_has_limiter = True
|
|
||||||
except ImportError:
|
|
||||||
_has_limiter = False
|
|
||||||
try:
|
|
||||||
from flask_compress import Compress
|
|
||||||
_has_compress = True
|
|
||||||
except ImportError:
|
|
||||||
_has_compress = False
|
|
||||||
try:
|
|
||||||
from flask_wtf.csrf import CSRFProtect
|
|
||||||
_has_csrf = True
|
|
||||||
except ImportError:
|
|
||||||
_has_csrf = False
|
|
||||||
# Track application start time for uptime calculation
|
# Track application start time for uptime calculation
|
||||||
import contextlib
|
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
_app_start_time = _time.time()
|
_app_start_time = _time.time()
|
||||||
logger = logging.getLogger('intercept.database')
|
logger = logging.getLogger('intercept.database')
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
def _load_or_generate_secret_key():
|
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||||
"""Load secret key from env var or instance file, generating if needed."""
|
|
||||||
env_key = os.environ.get('INTERCEPT_SECRET_KEY')
|
|
||||||
if env_key:
|
|
||||||
return env_key
|
|
||||||
key_path = Path('instance/secret.key')
|
|
||||||
if key_path.exists():
|
|
||||||
return key_path.read_text().strip()
|
|
||||||
key_path.parent.mkdir(exist_ok=True)
|
|
||||||
key = os.urandom(32).hex()
|
|
||||||
key_path.write_text(key)
|
|
||||||
return key
|
|
||||||
|
|
||||||
app.secret_key = _load_or_generate_secret_key()
|
|
||||||
|
|
||||||
# Set up HTTP compression (gzip/brotli for HTML, CSS, JS, JSON)
|
|
||||||
if _has_compress:
|
|
||||||
Compress(app)
|
|
||||||
else:
|
|
||||||
logging.getLogger('intercept').warning(
|
|
||||||
"flask-compress not installed – HTTP compression disabled. "
|
|
||||||
"Install with: pip install flask-compress"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set up rate limiting
|
# Set up rate limiting
|
||||||
if _has_limiter:
|
|
||||||
limiter = Limiter(
|
limiter = Limiter(
|
||||||
key_func=get_remote_address,
|
key_func=get_remote_address, # Identifies the user by their IP
|
||||||
app=app,
|
app=app,
|
||||||
storage_uri="memory://",
|
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logging.getLogger('intercept').warning(
|
|
||||||
"flask-limiter not installed – rate limiting disabled. "
|
|
||||||
"Install with: pip install flask-limiter"
|
|
||||||
)
|
|
||||||
class _NoopLimiter:
|
|
||||||
"""Stub so @limiter.limit() decorators are silently ignored."""
|
|
||||||
def limit(self, *a, **kw):
|
|
||||||
def decorator(f):
|
|
||||||
return f
|
|
||||||
return decorator
|
|
||||||
limiter = _NoopLimiter()
|
|
||||||
|
|
||||||
# Set up CSRF protection
|
|
||||||
if _has_csrf:
|
|
||||||
csrf = CSRFProtect(app)
|
|
||||||
else:
|
|
||||||
logging.getLogger('intercept').warning(
|
|
||||||
"flask-wtf not installed – CSRF protection disabled. "
|
|
||||||
"Install with: pip install flask-wtf"
|
|
||||||
)
|
|
||||||
csrf = None
|
|
||||||
|
|
||||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||||
@@ -163,12 +89,6 @@ def add_security_headers(response):
|
|||||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
# Permissions policy (disable unnecessary features)
|
# Permissions policy (disable unnecessary features)
|
||||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||||
# Cache-Control for static assets
|
|
||||||
if request.path.startswith('/static/'):
|
|
||||||
if '/vendor/' in request.path:
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days for vendored libs
|
|
||||||
else:
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=86400' # 24h for app assets
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -180,24 +100,11 @@ def add_security_headers(response):
|
|||||||
def inject_offline_settings():
|
def inject_offline_settings():
|
||||||
"""Inject offline settings into all templates."""
|
"""Inject offline settings into all templates."""
|
||||||
from utils.database import get_setting
|
from utils.database import get_setting
|
||||||
|
|
||||||
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
|
||||||
# third-party tracker/storage defenses in strict browsers.
|
|
||||||
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
|
|
||||||
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
|
|
||||||
if assets_source not in ('local', 'cdn'):
|
|
||||||
assets_source = 'local'
|
|
||||||
if fonts_source not in ('local', 'cdn'):
|
|
||||||
fonts_source = 'local'
|
|
||||||
# Force local delivery for core dashboard pages.
|
|
||||||
assets_source = 'local'
|
|
||||||
fonts_source = 'local'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'offline_settings': {
|
'offline_settings': {
|
||||||
'enabled': get_setting('offline.enabled', False),
|
'enabled': get_setting('offline.enabled', False),
|
||||||
'assets_source': assets_source,
|
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||||
'fonts_source': fonts_source,
|
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||||
}
|
}
|
||||||
@@ -243,11 +150,6 @@ acars_process = None
|
|||||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
acars_lock = threading.Lock()
|
acars_lock = threading.Lock()
|
||||||
|
|
||||||
# VDL2 aircraft datalink
|
|
||||||
vdl2_process = None
|
|
||||||
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
||||||
vdl2_lock = threading.Lock()
|
|
||||||
|
|
||||||
# APRS amateur radio tracking
|
# APRS amateur radio tracking
|
||||||
aprs_process = None
|
aprs_process = None
|
||||||
aprs_rtl_process = None
|
aprs_rtl_process = None
|
||||||
@@ -270,34 +172,16 @@ dsc_rtl_process = None
|
|||||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
dsc_lock = threading.Lock()
|
dsc_lock = threading.Lock()
|
||||||
|
|
||||||
|
# DMR / Digital Voice
|
||||||
|
dmr_process = None
|
||||||
|
dmr_rtl_process = None
|
||||||
|
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
dmr_lock = threading.Lock()
|
||||||
|
|
||||||
# TSCM (Technical Surveillance Countermeasures)
|
# TSCM (Technical Surveillance Countermeasures)
|
||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
# SubGHz Transceiver (HackRF)
|
|
||||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
||||||
subghz_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Radiosonde weather balloon tracking
|
|
||||||
radiosonde_process = None
|
|
||||||
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
||||||
radiosonde_lock = threading.Lock()
|
|
||||||
|
|
||||||
# CW/Morse code decoder
|
|
||||||
morse_process = None
|
|
||||||
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
||||||
morse_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Meteor scatter detection
|
|
||||||
meteor_process = None
|
|
||||||
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
||||||
meteor_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Generic OOK signal decoder
|
|
||||||
ook_process = None
|
|
||||||
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
||||||
ook_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Deauth Attack Detection
|
# Deauth Attack Detection
|
||||||
deauth_detector = None
|
deauth_detector = None
|
||||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
@@ -352,63 +236,44 @@ cleanup_manager.register(deauth_alerts)
|
|||||||
# SDR DEVICE REGISTRY
|
# SDR DEVICE REGISTRY
|
||||||
# ============================================
|
# ============================================
|
||||||
# Tracks which mode is using which SDR device to prevent conflicts
|
# Tracks which mode is using which SDR device to prevent conflicts
|
||||||
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
|
# Key: device_index (int), Value: mode_name (str)
|
||||||
sdr_device_registry: dict[str, str] = {}
|
sdr_device_registry: dict[int, str] = {}
|
||||||
sdr_device_registry_lock = threading.Lock()
|
sdr_device_registry_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
|
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||||
"""Claim an SDR device for a mode.
|
"""Claim an SDR device for a mode.
|
||||||
|
|
||||||
Checks the in-app registry first, then probes the USB device to
|
|
||||||
catch stale handles held by external processes (e.g. a leftover
|
|
||||||
rtl_fm from a previous crash).
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to claim
|
device_index: The SDR device index to claim
|
||||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Error message if device is in use, None if successfully claimed
|
Error message if device is in use, None if successfully claimed
|
||||||
"""
|
"""
|
||||||
key = f"{sdr_type}:{device_index}"
|
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
if key in sdr_device_registry:
|
if device_index in sdr_device_registry:
|
||||||
in_use_by = sdr_device_registry[key]
|
in_use_by = sdr_device_registry[device_index]
|
||||||
return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||||
|
sdr_device_registry[device_index] = mode_name
|
||||||
# Probe the USB device to catch external processes holding the handle
|
|
||||||
if sdr_type == 'rtlsdr':
|
|
||||||
try:
|
|
||||||
from utils.sdr.detection import probe_rtlsdr_device
|
|
||||||
usb_error = probe_rtlsdr_device(device_index)
|
|
||||||
if usb_error:
|
|
||||||
return usb_error
|
|
||||||
except Exception:
|
|
||||||
pass # If probe fails, let the caller proceed normally
|
|
||||||
|
|
||||||
sdr_device_registry[key] = mode_name
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
|
def release_sdr_device(device_index: int) -> None:
|
||||||
"""Release an SDR device from the registry.
|
"""Release an SDR device from the registry.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to release
|
device_index: The SDR device index to release
|
||||||
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
|
||||||
"""
|
"""
|
||||||
key = f"{sdr_type}:{device_index}"
|
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
sdr_device_registry.pop(key, None)
|
sdr_device_registry.pop(device_index, None)
|
||||||
|
|
||||||
|
|
||||||
def get_sdr_device_status() -> dict[str, str]:
|
def get_sdr_device_status() -> dict[int, str]:
|
||||||
"""Get current SDR device allocations.
|
"""Get current SDR device allocations.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping 'sdr_type:device_index' keys to mode names
|
Dictionary mapping device indices to mode names
|
||||||
"""
|
"""
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
return dict(sdr_device_registry)
|
return dict(sdr_device_registry)
|
||||||
@@ -427,10 +292,6 @@ def require_login():
|
|||||||
if request.path.startswith('/listening/audio/'):
|
if request.path.startswith('/listening/audio/'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Allow WebSocket upgrade requests (page load already required auth)
|
|
||||||
if request.path.startswith('/ws/'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Controller API endpoints use API key auth, not session auth
|
# Controller API endpoints use API key auth, not session auth
|
||||||
# Allow agent push/pull endpoints without session login
|
# Allow agent push/pull endpoints without session login
|
||||||
if request.path.startswith('/controller/'):
|
if request.path.startswith('/controller/'):
|
||||||
@@ -491,8 +352,6 @@ def index() -> str:
|
|||||||
version=VERSION,
|
version=VERSION,
|
||||||
changelog=CHANGELOG,
|
changelog=CHANGELOG,
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
default_latitude=DEFAULT_LATITUDE,
|
|
||||||
default_longitude=DEFAULT_LONGITUDE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -501,18 +360,6 @@ def favicon() -> Response:
|
|||||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/sw.js')
|
|
||||||
def service_worker() -> Response:
|
|
||||||
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
|
||||||
resp.headers['Service-Worker-Allowed'] = '/'
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/manifest.json')
|
|
||||||
def pwa_manifest() -> Response:
|
|
||||||
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices')
|
@app.route('/devices')
|
||||||
def get_devices() -> Response:
|
def get_devices() -> Response:
|
||||||
"""Get all detected SDR devices with hardware type info."""
|
"""Get all detected SDR devices with hardware type info."""
|
||||||
@@ -529,9 +376,8 @@ def get_devices_status() -> Response:
|
|||||||
result = []
|
result = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
d = device.to_dict()
|
d = device.to_dict()
|
||||||
key = f"{device.sdr_type.value}:{device.index}"
|
d['in_use'] = device.index in registry
|
||||||
d['in_use'] = key in registry
|
d['used_by'] = registry.get(device.index)
|
||||||
d['used_by'] = registry.get(key)
|
|
||||||
result.append(d)
|
result.append(d)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
@@ -777,192 +623,56 @@ def export_bluetooth() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _get_subghz_active() -> bool:
|
|
||||||
"""Check if SubGHz manager has an active process."""
|
|
||||||
try:
|
|
||||||
from utils.subghz import get_subghz_manager
|
|
||||||
return get_subghz_manager().active_mode != 'idle'
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _get_singleton_running(module_path: str, getter_name: str, attr: str) -> bool:
|
|
||||||
"""Safely check if a singleton-based mode is running without creating instances."""
|
|
||||||
try:
|
|
||||||
import importlib
|
|
||||||
mod = importlib.import_module(module_path)
|
|
||||||
getter = getattr(mod, getter_name)
|
|
||||||
instance = getter()
|
|
||||||
if instance is None:
|
|
||||||
return False
|
|
||||||
return bool(getattr(instance, attr, False))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _get_tscm_active() -> bool:
|
|
||||||
"""Check if a TSCM sweep is running."""
|
|
||||||
try:
|
|
||||||
from routes.tscm import _sweep_running
|
|
||||||
return bool(_sweep_running)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _get_bluetooth_health() -> tuple[bool, int]:
|
|
||||||
"""Return Bluetooth active state and best-effort device count."""
|
|
||||||
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
|
||||||
scanner_running = False
|
|
||||||
scanner_count = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
scanner_running = bool(bt_scanner.is_scanning)
|
|
||||||
scanner_count = int(bt_scanner.device_count)
|
|
||||||
except Exception:
|
|
||||||
scanner_running = False
|
|
||||||
scanner_count = 0
|
|
||||||
|
|
||||||
locate_running = False
|
|
||||||
try:
|
|
||||||
from utils.bt_locate import get_locate_session
|
|
||||||
session = get_locate_session()
|
|
||||||
if session and getattr(session, 'active', False):
|
|
||||||
scanner = getattr(session, '_scanner', None)
|
|
||||||
locate_running = bool(scanner and scanner.is_scanning)
|
|
||||||
except Exception:
|
|
||||||
locate_running = False
|
|
||||||
|
|
||||||
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_wifi_health() -> tuple[bool, int, int]:
|
|
||||||
"""Return WiFi active state and best-effort network/client counts."""
|
|
||||||
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
|
||||||
scanner_running = False
|
|
||||||
scanner_networks = 0
|
|
||||||
scanner_clients = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
status = wifi_scanner.get_status()
|
|
||||||
scanner_running = bool(status.is_scanning)
|
|
||||||
scanner_networks = int(status.networks_found or 0)
|
|
||||||
scanner_clients = int(status.clients_found or 0)
|
|
||||||
except Exception:
|
|
||||||
scanner_running = False
|
|
||||||
scanner_networks = 0
|
|
||||||
scanner_clients = 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
legacy_running or scanner_running,
|
|
||||||
max(len(wifi_networks), scanner_networks),
|
|
||||||
max(len(wifi_clients), scanner_clients),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
import platform
|
|
||||||
import time
|
import time
|
||||||
bt_active, bt_device_count = _get_bluetooth_health()
|
return jsonify({
|
||||||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
'status': 'healthy',
|
||||||
|
|
||||||
# Database health check
|
|
||||||
db_ok = True
|
|
||||||
try:
|
|
||||||
from utils.database import get_connection
|
|
||||||
get_connection().execute('SELECT 1')
|
|
||||||
except Exception:
|
|
||||||
db_ok = False
|
|
||||||
|
|
||||||
# SDR device count (cached, non-blocking)
|
|
||||||
sdr_count = 0
|
|
||||||
try:
|
|
||||||
from utils.sdr.detection import get_cached_devices
|
|
||||||
cached = get_cached_devices()
|
|
||||||
if cached is not None:
|
|
||||||
sdr_count = len(cached)
|
|
||||||
except (ImportError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
overall_status = 'healthy' if db_ok else 'degraded'
|
|
||||||
status_code = 200 if db_ok else 503
|
|
||||||
|
|
||||||
response = jsonify({
|
|
||||||
'status': overall_status,
|
|
||||||
'version': VERSION,
|
'version': VERSION,
|
||||||
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||||
'system': {
|
|
||||||
'python_version': platform.python_version(),
|
|
||||||
'platform': platform.platform(),
|
|
||||||
},
|
|
||||||
'database': db_ok,
|
|
||||||
'sdr_devices': sdr_count,
|
|
||||||
'rate_limiting': _has_limiter,
|
|
||||||
'processes': {
|
'processes': {
|
||||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||||
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
|
|
||||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_active,
|
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||||
'bluetooth': bt_active,
|
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False),
|
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
|
||||||
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
|
|
||||||
'subghz': _get_subghz_active(),
|
|
||||||
'rtlamr': rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False),
|
|
||||||
'meshtastic': _get_singleton_running('utils.meshtastic', 'get_meshtastic_client', 'is_running'),
|
|
||||||
'sstv': _get_singleton_running('utils.sstv', 'get_sstv_decoder', 'is_running'),
|
|
||||||
'weathersat': _get_singleton_running('utils.weather_sat', 'get_weather_sat_decoder', 'is_running'),
|
|
||||||
'wefax': _get_singleton_running('utils.wefax', 'get_wefax_decoder', 'is_running'),
|
|
||||||
'sstv_general': _get_singleton_running('utils.sstv', 'get_general_sstv_decoder', 'is_running'),
|
|
||||||
'tscm': _get_tscm_active(),
|
|
||||||
'gps': _get_singleton_running('utils.gps', 'get_gps_reader', 'is_running'),
|
|
||||||
'bt_locate': _get_singleton_running('utils.bt_locate', 'get_locate_session', 'is_active'),
|
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
'vessel_count': len(ais_vessels),
|
'vessel_count': len(ais_vessels),
|
||||||
'wifi_networks_count': wifi_network_count,
|
'wifi_networks_count': len(wifi_networks),
|
||||||
'wifi_clients_count': wifi_client_count,
|
'wifi_clients_count': len(wifi_clients),
|
||||||
'bt_devices_count': bt_device_count,
|
'bt_devices_count': len(bt_devices),
|
||||||
'dsc_messages_count': len(dsc_messages),
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
response.status_code = status_code
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
@(csrf.exempt if csrf else lambda f: f)
|
|
||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global vdl2_process, morse_process, radiosonde_process, ook_process
|
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
|
global dmr_process, dmr_rtl_process
|
||||||
|
|
||||||
# Import modules to reset their state
|
# Import adsb and ais modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
from routes import ais as ais_module
|
from routes import ais as ais_module
|
||||||
from routes import radiosonde as radiosonde_module
|
|
||||||
from utils.bluetooth import reset_bluetooth_scanner
|
from utils.bluetooth import reset_bluetooth_scanner
|
||||||
|
|
||||||
killed = []
|
killed = []
|
||||||
processes_to_kill = [
|
processes_to_kill = [
|
||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump',
|
'hcitool', 'bluetoothctl', 'dsd',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep',
|
|
||||||
'auto_rx'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -992,34 +702,10 @@ def kill_all() -> Response:
|
|||||||
ais_process = None
|
ais_process = None
|
||||||
ais_module.ais_running = False
|
ais_module.ais_running = False
|
||||||
|
|
||||||
# Reset Radiosonde state
|
|
||||||
with radiosonde_lock:
|
|
||||||
radiosonde_process = None
|
|
||||||
radiosonde_module.radiosonde_running = False
|
|
||||||
|
|
||||||
# Reset ACARS state
|
# Reset ACARS state
|
||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
acars_process = None
|
||||||
|
|
||||||
# Reset VDL2 state
|
|
||||||
with vdl2_lock:
|
|
||||||
vdl2_process = None
|
|
||||||
|
|
||||||
# Reset Morse state
|
|
||||||
with morse_lock:
|
|
||||||
morse_process = None
|
|
||||||
|
|
||||||
# Reset OOK state (full cleanup: parser thread, pipes, SDR release)
|
|
||||||
with ook_lock:
|
|
||||||
try:
|
|
||||||
from routes.ook import cleanup_ook
|
|
||||||
cleanup_ook(emit_status=False)
|
|
||||||
except Exception:
|
|
||||||
if ook_process:
|
|
||||||
safe_terminate(ook_process)
|
|
||||||
unregister_process(ook_process)
|
|
||||||
ook_process = None
|
|
||||||
|
|
||||||
# Reset APRS state
|
# Reset APRS state
|
||||||
with aprs_lock:
|
with aprs_lock:
|
||||||
aprs_process = None
|
aprs_process = None
|
||||||
@@ -1030,6 +716,11 @@ def kill_all() -> Response:
|
|||||||
dsc_process = None
|
dsc_process = None
|
||||||
dsc_rtl_process = None
|
dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Reset DMR state
|
||||||
|
with dmr_lock:
|
||||||
|
dmr_process = None
|
||||||
|
dmr_rtl_process = None
|
||||||
|
|
||||||
# Reset Bluetooth state (legacy)
|
# Reset Bluetooth state (legacy)
|
||||||
with bt_lock:
|
with bt_lock:
|
||||||
if bt_process:
|
if bt_process:
|
||||||
@@ -1037,21 +728,16 @@ def kill_all() -> Response:
|
|||||||
bt_process.terminate()
|
bt_process.terminate()
|
||||||
bt_process.wait(timeout=2)
|
bt_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
bt_process.kill()
|
bt_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
bt_process = None
|
bt_process = None
|
||||||
|
|
||||||
# Reset Bluetooth v2 scanner
|
# Reset Bluetooth v2 scanner
|
||||||
try:
|
try:
|
||||||
reset_bluetooth_scanner()
|
reset_bluetooth_scanner()
|
||||||
killed.append('bluetooth')
|
killed.append('bluetooth_scanner')
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Reset SubGHz state
|
|
||||||
try:
|
|
||||||
from utils.subghz import get_subghz_manager
|
|
||||||
get_subghz_manager().stop_all()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1062,143 +748,9 @@ def kill_all() -> Response:
|
|||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
|
|
||||||
"""Generate a self-signed certificate if one doesn't already exist.
|
|
||||||
|
|
||||||
Returns (cert_path, key_path) tuple.
|
|
||||||
"""
|
|
||||||
cert_path = os.path.join(cert_dir, 'intercept.crt')
|
|
||||||
key_path = os.path.join(cert_dir, 'intercept.key')
|
|
||||||
|
|
||||||
if os.path.exists(cert_path) and os.path.exists(key_path):
|
|
||||||
print(f"Using existing SSL certificate: {cert_path}")
|
|
||||||
return cert_path, key_path
|
|
||||||
|
|
||||||
os.makedirs(cert_dir, exist_ok=True)
|
|
||||||
print("Generating self-signed SSL certificate...")
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
result = subprocess.run([
|
|
||||||
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
|
||||||
'-keyout', key_path, '-out', cert_path,
|
|
||||||
'-days', '365', '-nodes',
|
|
||||||
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
|
|
||||||
], capture_output=True, text=True)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
|
|
||||||
|
|
||||||
print(f"SSL certificate generated: {cert_path}")
|
|
||||||
return cert_path, key_path
|
|
||||||
|
|
||||||
|
|
||||||
_app_initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
def _init_app() -> None:
|
|
||||||
"""Initialize blueprints, database, and websockets.
|
|
||||||
|
|
||||||
Safe to call multiple times — subsequent calls are no-ops.
|
|
||||||
Called automatically at module level for gunicorn, and also
|
|
||||||
from main() for the Flask dev server path.
|
|
||||||
|
|
||||||
Heavy/network operations (TLE updates, process cleanup) are
|
|
||||||
deferred to a background thread so the worker can serve
|
|
||||||
requests immediately.
|
|
||||||
"""
|
|
||||||
global _app_initialized
|
|
||||||
if _app_initialized:
|
|
||||||
return
|
|
||||||
_app_initialized = True
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Initialize database for settings storage
|
|
||||||
from utils.database import init_db
|
|
||||||
init_db()
|
|
||||||
|
|
||||||
# Register blueprints (essential — without these, all routes 404)
|
|
||||||
from routes import register_blueprints
|
|
||||||
register_blueprints(app)
|
|
||||||
|
|
||||||
# Initialize WebSocket for audio streaming
|
|
||||||
try:
|
|
||||||
from routes.audio_websocket import init_audio_websocket
|
|
||||||
init_audio_websocket(app)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Initialize KiwiSDR WebSocket audio proxy
|
|
||||||
try:
|
|
||||||
from routes.websdr import init_websdr_audio
|
|
||||||
init_websdr_audio(app)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Initialize WebSocket for waterfall streaming
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import init_waterfall_websocket
|
|
||||||
init_waterfall_websocket(app)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Initialize WebSocket for meteor scatter monitoring
|
|
||||||
try:
|
|
||||||
from routes.meteor_websocket import init_meteor_websocket
|
|
||||||
init_meteor_websocket(app)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Defer heavy/network operations so the worker can serve requests immediately
|
|
||||||
import threading
|
|
||||||
|
|
||||||
def _deferred_init():
|
|
||||||
"""Run heavy initialization after a short delay."""
|
|
||||||
import time
|
|
||||||
time.sleep(1) # Let the worker start serving first
|
|
||||||
|
|
||||||
# Clean up stale processes from previous runs
|
|
||||||
try:
|
|
||||||
cleanup_stale_processes()
|
|
||||||
cleanup_stale_dump1090()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Stale process cleanup failed: {e}")
|
|
||||||
|
|
||||||
# Register and start database cleanup
|
|
||||||
try:
|
|
||||||
from utils.database import (
|
|
||||||
cleanup_old_dsc_alerts,
|
|
||||||
cleanup_old_payloads,
|
|
||||||
cleanup_old_signal_history,
|
|
||||||
cleanup_old_timeline_entries,
|
|
||||||
)
|
|
||||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
|
|
||||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
|
|
||||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440)
|
|
||||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
|
|
||||||
cleanup_manager.start()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Cleanup manager init failed: {e}")
|
|
||||||
|
|
||||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
|
||||||
try:
|
|
||||||
from routes.satellite import init_tle_auto_refresh
|
|
||||||
if not os.environ.get('TESTING'):
|
|
||||||
init_tle_auto_refresh()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
|
||||||
|
|
||||||
threading.Thread(target=_deferred_init, daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
# Auto-initialize when imported (e.g. by gunicorn)
|
|
||||||
_init_app()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -1222,12 +774,6 @@ def main() -> None:
|
|||||||
default=config.DEBUG,
|
default=config.DEBUG,
|
||||||
help='Enable debug mode'
|
help='Enable debug mode'
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
'--https',
|
|
||||||
action='store_true',
|
|
||||||
default=config.HTTPS,
|
|
||||||
help='Enable HTTPS with self-signed certificate'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--check-deps',
|
'--check-deps',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
@@ -1240,7 +786,7 @@ def main() -> None:
|
|||||||
results = check_all_dependencies()
|
results = check_all_dependencies()
|
||||||
print("Dependency Status:")
|
print("Dependency Status:")
|
||||||
print("-" * 40)
|
print("-" * 40)
|
||||||
for _mode, info in results.items():
|
for mode, info in results.items():
|
||||||
status = "✓" if info['ready'] else "✗"
|
status = "✓" if info['ready'] else "✗"
|
||||||
print(f"\n{status} {info['name']}:")
|
print(f"\n{status} {info['name']}:")
|
||||||
for tool, tool_info in info['tools'].items():
|
for tool, tool_info in info['tools'].items():
|
||||||
@@ -1277,21 +823,53 @@ def main() -> None:
|
|||||||
print("Running as root - full capabilities enabled")
|
print("Running as root - full capabilities enabled")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Ensure app is initialized (no-op if already done by module-level call)
|
# Clean up any stale processes from previous runs
|
||||||
_init_app()
|
cleanup_stale_processes()
|
||||||
|
|
||||||
# Configure SSL if HTTPS is enabled
|
# Initialize database for settings storage
|
||||||
ssl_context = None
|
from utils.database import init_db
|
||||||
if args.https:
|
init_db()
|
||||||
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
|
|
||||||
if config.SSL_CERT and config.SSL_KEY:
|
# Start automatic cleanup of stale data entries
|
||||||
ssl_context = (config.SSL_CERT, config.SSL_KEY)
|
cleanup_manager.start()
|
||||||
print(f"Using provided SSL certificate: {config.SSL_CERT}")
|
|
||||||
|
# Register blueprints
|
||||||
|
from routes import register_blueprints
|
||||||
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Update TLE data in background thread (non-blocking)
|
||||||
|
def update_tle_background():
|
||||||
|
try:
|
||||||
|
from routes.satellite import refresh_tle_data
|
||||||
|
print("Updating satellite TLE data from CelesTrak...")
|
||||||
|
updated = refresh_tle_data()
|
||||||
|
if updated:
|
||||||
|
print(f"TLE data updated for: {', '.join(updated)}")
|
||||||
else:
|
else:
|
||||||
ssl_context = _ensure_self_signed_cert(cert_dir)
|
print("TLE update: No satellites updated (may be offline)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"TLE update failed (will use cached data): {e}")
|
||||||
|
|
||||||
protocol = 'https' if ssl_context else 'http'
|
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
|
||||||
print(f"Open {protocol}://localhost:{args.port} in your browser")
|
tle_thread.start()
|
||||||
|
|
||||||
|
# Initialize WebSocket for audio streaming
|
||||||
|
try:
|
||||||
|
from routes.audio_websocket import init_audio_websocket
|
||||||
|
init_audio_websocket(app)
|
||||||
|
print("WebSocket audio streaming enabled")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||||
|
|
||||||
|
# Initialize KiwiSDR WebSocket audio proxy
|
||||||
|
try:
|
||||||
|
from routes.websdr import init_websdr_audio
|
||||||
|
init_websdr_audio(app)
|
||||||
|
print("KiwiSDR audio proxy enabled")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||||
|
|
||||||
|
print(f"Open http://localhost:{args.port} in your browser")
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
print()
|
print()
|
||||||
@@ -1303,5 +881,4 @@ def main() -> None:
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
threaded=True,
|
threaded=True,
|
||||||
load_dotenv=False,
|
load_dotenv=False,
|
||||||
ssl_context=ssl_context,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# INTERCEPT - Multi-architecture Docker image builder
|
|
||||||
#
|
|
||||||
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
|
||||||
# Run this on your x64 machine to cross-compile the arm64 image
|
|
||||||
# instead of building natively on the RPi5.
|
|
||||||
#
|
|
||||||
# Prerequisites (one-time setup):
|
|
||||||
# docker run --privileged --rm tonistiigi/binfmt --install all
|
|
||||||
# docker buildx create --name intercept-builder --use --bootstrap
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./build-multiarch.sh # Build both platforms, load locally
|
|
||||||
# ./build-multiarch.sh --push # Build and push to registry
|
|
||||||
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
|
||||||
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
|
||||||
#
|
|
||||||
# Environment variables:
|
|
||||||
# REGISTRY - Container registry (default: docker.io/library)
|
|
||||||
# IMAGE_NAME - Image name (default: intercept)
|
|
||||||
# IMAGE_TAG - Image tag (default: latest)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
REGISTRY="${REGISTRY:-}"
|
|
||||||
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
|
||||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
|
||||||
BUILDER_NAME="intercept-builder"
|
|
||||||
PLATFORMS="linux/amd64,linux/arm64"
|
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
PUSH=false
|
|
||||||
LOAD=false
|
|
||||||
ARM64_ONLY=false
|
|
||||||
|
|
||||||
for arg in "$@"; do
|
|
||||||
case $arg in
|
|
||||||
--push) PUSH=true ;;
|
|
||||||
--load) LOAD=true ;;
|
|
||||||
--arm64-only)
|
|
||||||
ARM64_ONLY=true
|
|
||||||
PLATFORMS="linux/arm64"
|
|
||||||
;;
|
|
||||||
--amd64-only)
|
|
||||||
PLATFORMS="linux/amd64"
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " --push Push to container registry"
|
|
||||||
echo " --load Load into local Docker (single platform only)"
|
|
||||||
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
|
||||||
echo " --amd64-only Build amd64 only"
|
|
||||||
echo ""
|
|
||||||
echo "Environment variables:"
|
|
||||||
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
|
||||||
echo " IMAGE_NAME Image name (default: intercept)"
|
|
||||||
echo " IMAGE_TAG Image tag (default: latest)"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
echo " $0 --push # Build both, push"
|
|
||||||
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
|
||||||
echo " $0 --arm64-only --load # Build arm64, load locally"
|
|
||||||
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $arg"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Build full image reference
|
|
||||||
if [ -n "$REGISTRY" ]; then
|
|
||||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
else
|
|
||||||
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "============================================"
|
|
||||||
echo " INTERCEPT Multi-Architecture Builder"
|
|
||||||
echo "============================================"
|
|
||||||
echo " Image: ${FULL_IMAGE}"
|
|
||||||
echo " Platforms: ${PLATFORMS}"
|
|
||||||
echo " Push: ${PUSH}"
|
|
||||||
echo "============================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if buildx builder exists, create if not
|
|
||||||
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
|
||||||
echo "Creating buildx builder: ${BUILDER_NAME}"
|
|
||||||
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
|
||||||
|
|
||||||
# Check for QEMU support
|
|
||||||
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
|
||||||
echo "WARNING: QEMU binfmt setup may have failed."
|
|
||||||
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
docker buildx use "$BUILDER_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build command
|
|
||||||
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
|
||||||
|
|
||||||
if [ "$PUSH" = true ]; then
|
|
||||||
BUILD_CMD="${BUILD_CMD} --push"
|
|
||||||
echo "Will push to: ${FULL_IMAGE}"
|
|
||||||
elif [ "$LOAD" = true ]; then
|
|
||||||
# --load only works with single platform
|
|
||||||
if echo "$PLATFORMS" | grep -q ","; then
|
|
||||||
echo "ERROR: --load only works with a single platform."
|
|
||||||
echo "Use --arm64-only or --amd64-only with --load."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
BUILD_CMD="${BUILD_CMD} --load"
|
|
||||||
echo "Will load into local Docker"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Building..."
|
|
||||||
echo "Command: ${BUILD_CMD} ."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
$BUILD_CMD .
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "============================================"
|
|
||||||
echo " Build complete!"
|
|
||||||
if [ "$PUSH" = true ]; then
|
|
||||||
echo " Image pushed to: ${FULL_IMAGE}"
|
|
||||||
echo ""
|
|
||||||
echo " Pull on RPi5:"
|
|
||||||
echo " docker pull ${FULL_IMAGE}"
|
|
||||||
fi
|
|
||||||
echo "============================================"
|
|
||||||
@@ -7,236 +7,16 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.26.8"
|
VERSION = "2.14.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
{
|
|
||||||
"version": "2.26.8",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix acarsdec build failure on macOS (HOST_NAME_MAX undefined)",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.7",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix health check SDR detection on macOS (timeout command not available)",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.6",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix oversized branded 'i' logo on Aircraft & Vessel dashboards",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.5",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix database errors crashing the entire UI — pages now degrade gracefully",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.4",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix Environment Configurator crash when .env exists but variable is missing",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.3",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix SatDump AVX2 crash on older CPUs — build now targets baseline x86-64",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.2",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix Docker startup crash — data/ Python package was excluded by .dockerignore",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.1",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix default admin credentials — now matches README (admin:admin)",
|
|
||||||
"Admin password changes in config.py / env vars now sync to DB on restart",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.26.0",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Fix SSE fanout thread crash when source queue is None during shutdown",
|
|
||||||
"Fix branded 'i' logo FOUC (flash of unstyled content) on first page load",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.25.0",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"UI/UX overhaul — SSEManager with exponential backoff and connection status indicator",
|
|
||||||
"Accessibility improvements — aria-labels, form label associations, keyboard list navigation",
|
|
||||||
"Destructive action confirmation modals replace native confirm() dialogs",
|
|
||||||
"CSS variable adoption, inline style extraction, and reduced !important usage",
|
|
||||||
"Loading button states, actionable error reporting, and mobile UX polish",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.24.0",
|
|
||||||
"date": "March 2026",
|
|
||||||
"highlights": [
|
|
||||||
"WiFi Locate mode — locate access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones",
|
|
||||||
"Mobile navigation reorganized into labeled groups for better usability",
|
|
||||||
"flask-limiter made optional for graceful degradation",
|
|
||||||
"Radiosonde setup fix — missing semver dependency",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.23.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Radiosonde weather balloon tracking mode with telemetry, map, and station distance",
|
|
||||||
"CW/Morse code decoder with Goertzel tone detection and OOK envelope mode",
|
|
||||||
"WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline",
|
|
||||||
"System Health monitoring mode with telemetry dashboard",
|
|
||||||
"HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts",
|
|
||||||
"Production server (start.sh) with gunicorn + gevent for concurrent multi-client support",
|
|
||||||
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
|
|
||||||
"GPS mode upgraded to textured 3D globe",
|
|
||||||
"Destroy lifecycle added to all mode modules to prevent resource leaks",
|
|
||||||
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.22.3",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Waterfall control panel no longer shows as unstyled text on first visit",
|
|
||||||
"WebSDR globe renders correctly on first page load without requiring a refresh",
|
|
||||||
"Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header",
|
|
||||||
"Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup",
|
|
||||||
"Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.22.1",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
|
|
||||||
"Voice alerts for configurable event notifications across modes",
|
|
||||||
"Signal fingerprinting mode for RF device identification and pattern analysis",
|
|
||||||
"SignalID integration via SigIDWiki API for automatic signal classification",
|
|
||||||
"PWA support: installable web app with service worker and manifest",
|
|
||||||
"Mode stop responsiveness improvements with faster timeout handling",
|
|
||||||
"Navigation performance instrumentation and smoother mode transitions",
|
|
||||||
"Pager, sensor, and SSTV real-time signal scope visualization",
|
|
||||||
"ADS-B MSG2 surface movement parsing for ground vehicle tracking",
|
|
||||||
"WebSDR major overhaul with improved receiver management and audio streaming",
|
|
||||||
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
|
|
||||||
"Help modal updated with ACARS and VDL2 mode descriptions",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.21.1",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"BT Locate map first-load fix with render stabilization retries during initial mode open",
|
|
||||||
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
|
|
||||||
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.21.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Global map theme refresh with improved contrast and cross-dashboard consistency",
|
|
||||||
"Cross-app UX updates for accessibility, mode consistency, and render performance",
|
|
||||||
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
|
|
||||||
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
|
||||||
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
|
||||||
"Analytics enhancements with operational insights and temporal pattern panels",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.20.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
|
||||||
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
|
||||||
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
|
||||||
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
|
||||||
"No SDR hardware required — all data from public APIs with server-side caching",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.19.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
|
|
||||||
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
|
|
||||||
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
|
|
||||||
"Help modal updated with all modes and correct SVG icons",
|
|
||||||
"Setup script overhauled for reliability and macOS compatibility",
|
|
||||||
"GPS fix for preserving satellites across DOP-only SKY messages",
|
|
||||||
"Fix gpsd deadlock causing GPS connect to hang",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.18.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
|
|
||||||
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
|
|
||||||
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
|
|
||||||
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
|
||||||
"GPS: error state indicator and UI refinements",
|
|
||||||
"Proximity radar and signal card UI improvements",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.17.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
|
|
||||||
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
|
|
||||||
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
|
|
||||||
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.16.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
|
|
||||||
"Weather satellite auto-scheduler with polar plot and ground track map",
|
|
||||||
"SatDump support for local (non-Docker) installs via setup.sh",
|
|
||||||
"Shared waterfall UI across SDR modes",
|
|
||||||
"Listening post audio stuttering fix and SDR race condition fixes",
|
|
||||||
"Multi-arch Docker build support (amd64 + arm64)",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2.15.0",
|
|
||||||
"date": "February 2026",
|
|
||||||
"highlights": [
|
|
||||||
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
|
|
||||||
"Cross-module frequency routing from Listening Post to decoders",
|
|
||||||
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
|
||||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
|
||||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
|
||||||
"SDR device lock-up fix from unreleased device registry on crash",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "2.14.0",
|
"version": "2.14.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
"highlights": [
|
"highlights": [
|
||||||
|
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
|
||||||
|
"DMR visual synthesizer with event-driven spring-physics bars",
|
||||||
"HF SSTV general mode with predefined shortwave frequencies",
|
"HF SSTV general mode with predefined shortwave frequencies",
|
||||||
"WebSDR integration for remote HF/shortwave listening",
|
"WebSDR integration for remote HF/shortwave listening",
|
||||||
"Listening Post signal scanner and audio pipeline improvements",
|
"Listening Post signal scanner and audio pipeline improvements",
|
||||||
@@ -382,11 +162,6 @@ PORT = _get_env_int('PORT', 5050)
|
|||||||
DEBUG = _get_env_bool('DEBUG', False)
|
DEBUG = _get_env_bool('DEBUG', False)
|
||||||
THREADED = _get_env_bool('THREADED', True)
|
THREADED = _get_env_bool('THREADED', True)
|
||||||
|
|
||||||
# HTTPS / SSL settings
|
|
||||||
HTTPS = _get_env_bool('HTTPS', False)
|
|
||||||
SSL_CERT = _get_env('SSL_CERT', '')
|
|
||||||
SSL_KEY = _get_env('SSL_KEY', '')
|
|
||||||
|
|
||||||
# Default RTL-SDR settings
|
# Default RTL-SDR settings
|
||||||
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||||
@@ -423,61 +198,21 @@ ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
|||||||
|
|
||||||
# Observer location settings
|
# Observer location settings
|
||||||
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
||||||
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
|
|
||||||
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
|
|
||||||
|
|
||||||
# Satellite settings
|
# Satellite settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||||
|
|
||||||
# Weather satellite settings
|
|
||||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
|
||||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
|
|
||||||
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)
|
|
||||||
|
|
||||||
# WeFax (Weather Fax) settings
|
|
||||||
WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0)
|
|
||||||
WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050)
|
|
||||||
WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576)
|
|
||||||
WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120)
|
|
||||||
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30)
|
|
||||||
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_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)
|
|
||||||
|
|
||||||
# Radiosonde settings
|
|
||||||
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
|
|
||||||
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
|
|
||||||
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
|
|
||||||
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
|
|
||||||
|
|
||||||
# Update checking
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||||
|
|
||||||
# Alerting
|
|
||||||
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
|
||||||
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
|
||||||
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
|
||||||
|
|
||||||
# Admin credentials
|
# Admin credentials
|
||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure application logging."""
|
"""Configure application logging."""
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Data modules for INTERCEPT
|
# Data modules for INTERCEPT
|
||||||
from .oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
|
from .satellites import TLE_SATELLITES
|
||||||
from .patterns import (
|
from .patterns import (
|
||||||
AIRTAG_PREFIXES,
|
AIRTAG_PREFIXES,
|
||||||
DRONE_OUI_PREFIXES,
|
|
||||||
DRONE_SSID_PATTERNS,
|
|
||||||
SAMSUNG_TRACKER,
|
|
||||||
TILE_PREFIXES,
|
TILE_PREFIXES,
|
||||||
|
SAMSUNG_TRACKER,
|
||||||
|
DRONE_SSID_PATTERNS,
|
||||||
|
DRONE_OUI_PREFIXES,
|
||||||
)
|
)
|
||||||
from .satellites import TLE_SATELLITES
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.oui')
|
logger = logging.getLogger('intercept.oui')
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ def load_oui_database() -> dict[str, str] | None:
|
|||||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
||||||
try:
|
try:
|
||||||
if os.path.exists(oui_file):
|
if os.path.exists(oui_file):
|
||||||
with open(oui_file) as f:
|
with open(oui_file, 'r') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
# Remove comment fields
|
# Remove comment fields
|
||||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
return {k: v for k, v in data.items() if not k.startswith('_')}
|
||||||
|
|||||||
@@ -26,7 +26,4 @@ TLE_SATELLITES = {
|
|||||||
'METEOR-M2-3': ('METEOR-M2 3',
|
'METEOR-M2-3': ('METEOR-M2 3',
|
||||||
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
||||||
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
||||||
'METEOR-M2-4': ('METEOR-M2 4',
|
|
||||||
'1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998',
|
|
||||||
'2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (risk_level, category_name)
|
Tuple of (risk_level, category_name)
|
||||||
"""
|
"""
|
||||||
for _category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
||||||
for freq_range in ranges:
|
for freq_range in ranges:
|
||||||
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||||
return freq_range['risk'], freq_range['name']
|
return freq_range['risk'], freq_range['name']
|
||||||
@@ -378,7 +378,7 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N
|
|||||||
"""
|
"""
|
||||||
if device_name:
|
if device_name:
|
||||||
name_lower = device_name.lower()
|
name_lower = device_name.lower()
|
||||||
for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
for pattern in tracker_info.get('patterns', []):
|
for pattern in tracker_info.get('patterns', []):
|
||||||
if pattern in name_lower:
|
if pattern in name_lower:
|
||||||
return tracker_info
|
return tracker_info
|
||||||
@@ -394,7 +394,7 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N
|
|||||||
|
|
||||||
if len(mfr_bytes) >= 2:
|
if len(mfr_bytes) >= 2:
|
||||||
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
||||||
for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
if tracker_info.get('company_id') == company_id:
|
if tracker_info.get('company_id') == company_id:
|
||||||
return tracker_info
|
return tracker_info
|
||||||
|
|
||||||
|
|||||||
@@ -1,733 +0,0 @@
|
|||||||
{
|
|
||||||
"stations": [
|
|
||||||
{
|
|
||||||
"name": "USCG Kodiak",
|
|
||||||
"callsign": "NOJ",
|
|
||||||
"country": "US",
|
|
||||||
"city": "Kodiak, AK",
|
|
||||||
"coordinates": [57.78, -152.50],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 2054, "description": "Night"},
|
|
||||||
{"khz": 4298, "description": "Primary"},
|
|
||||||
{"khz": 8459, "description": "Day"},
|
|
||||||
{"khz": 12412.5, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"},
|
|
||||||
{"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"},
|
|
||||||
{"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"},
|
|
||||||
{"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "USCG Boston",
|
|
||||||
"callsign": "NMF",
|
|
||||||
"country": "US",
|
|
||||||
"city": "Boston, MA",
|
|
||||||
"coordinates": [42.36, -71.04],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4235, "description": "Night"},
|
|
||||||
{"khz": 6340.5, "description": "Primary"},
|
|
||||||
{"khz": 9110, "description": "Day"},
|
|
||||||
{"khz": 12750, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
|
||||||
{"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
|
||||||
{"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"},
|
|
||||||
{"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "22:00", "duration_min": 20, "content": "Satellite Image"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "USCG New Orleans",
|
|
||||||
"callsign": "NMG",
|
|
||||||
"country": "US",
|
|
||||||
"city": "New Orleans, LA",
|
|
||||||
"coordinates": [29.95, -90.07],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4317.9, "description": "Night"},
|
|
||||||
{"khz": 8503.9, "description": "Primary"},
|
|
||||||
{"khz": 12789.9, "description": "Day"},
|
|
||||||
{"khz": 17146.4, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "USCG Pt. Reyes",
|
|
||||||
"callsign": "NMC",
|
|
||||||
"country": "US",
|
|
||||||
"city": "Pt. Reyes, CA",
|
|
||||||
"coordinates": [38.07, -122.97],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4346, "description": "Night"},
|
|
||||||
{"khz": 8682, "description": "Primary"},
|
|
||||||
{"khz": 12786, "description": "Day"},
|
|
||||||
{"khz": 17151.2, "description": "Extended"},
|
|
||||||
{"khz": 22527, "description": "DX"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
|
||||||
{"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
|
||||||
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"},
|
|
||||||
{"utc": "23:20", "duration_min": 20, "content": "Satellite Image"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "USCG Honolulu",
|
|
||||||
"callsign": "KVM70",
|
|
||||||
"country": "US",
|
|
||||||
"city": "Honolulu, HI",
|
|
||||||
"coordinates": [21.31, -157.86],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 9982.5, "description": "Primary"},
|
|
||||||
{"khz": 11090, "description": "Day"},
|
|
||||||
{"khz": 16135, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "05:19", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "RN Northwood",
|
|
||||||
"callsign": "GYA",
|
|
||||||
"country": "GB",
|
|
||||||
"city": "Northwood, London",
|
|
||||||
"coordinates": [51.63, -0.42],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 2618.5, "description": "Night"},
|
|
||||||
{"khz": 3280.5, "description": "Night Alt"},
|
|
||||||
{"khz": 4610, "description": "Primary"},
|
|
||||||
{"khz": 6834, "description": "Day Alt"},
|
|
||||||
{"khz": 8040, "description": "Day"},
|
|
||||||
{"khz": 11086.5, "description": "Extended"},
|
|
||||||
{"khz": 12390, "description": "Persian Gulf"},
|
|
||||||
{"khz": 18261, "description": "DX"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"},
|
|
||||||
{"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"},
|
|
||||||
{"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DWD Hamburg/Pinneberg",
|
|
||||||
"callsign": "DDH",
|
|
||||||
"country": "DE",
|
|
||||||
"city": "Pinneberg",
|
|
||||||
"coordinates": [53.66, 9.80],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 3855, "description": "Night (DDH3, 10kW)"},
|
|
||||||
{"khz": 7880, "description": "Primary (DDK3, 20kW)"},
|
|
||||||
{"khz": 13882.5, "description": "Day (DDK6, 20kW)"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"},
|
|
||||||
{"utc": "07:15", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"},
|
|
||||||
{"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"},
|
|
||||||
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "15:20", "duration_min": 20, "content": "Extended Prog"},
|
|
||||||
{"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"},
|
|
||||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "21:15", "duration_min": 20, "content": "Surface Prog"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "JMA Tokyo",
|
|
||||||
"callsign": "JMH",
|
|
||||||
"country": "JP",
|
|
||||||
"city": "Tokyo",
|
|
||||||
"coordinates": [35.69, 139.69],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 3622.5, "description": "Night"},
|
|
||||||
{"khz": 7795, "description": "Primary"},
|
|
||||||
{"khz": 13988.5, "description": "Day"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"},
|
|
||||||
{"utc": "03:00", "duration_min": 20, "content": "Satellite Image"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"},
|
|
||||||
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
|
||||||
{"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "15:00", "duration_min": 20, "content": "Satellite Image"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Kyodo News Tokyo",
|
|
||||||
"callsign": "JJC",
|
|
||||||
"country": "JP",
|
|
||||||
"city": "Tokyo",
|
|
||||||
"coordinates": [35.69, 139.69],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4316, "description": "Night"},
|
|
||||||
{"khz": 8467.5, "description": "Primary"},
|
|
||||||
{"khz": 12745.5, "description": "Day"},
|
|
||||||
{"khz": 16971, "description": "Extended"},
|
|
||||||
{"khz": 17069.6, "description": "DX"},
|
|
||||||
{"khz": 22542, "description": "DX 2"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
|
||||||
{"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
|
||||||
{"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
|
||||||
{"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
|
||||||
{"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Kagoshima Fisheries",
|
|
||||||
"callsign": "JFX",
|
|
||||||
"country": "JP",
|
|
||||||
"city": "Kagoshima",
|
|
||||||
"coordinates": [31.60, 130.56],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4274, "description": "Night"},
|
|
||||||
{"khz": 8658, "description": "Primary"},
|
|
||||||
{"khz": 13074, "description": "Day"},
|
|
||||||
{"khz": 16907.5, "description": "Extended"},
|
|
||||||
{"khz": 22559.6, "description": "DX"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
|
||||||
{"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"},
|
|
||||||
{"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Current Chart"},
|
|
||||||
{"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"},
|
|
||||||
{"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "KMA Seoul",
|
|
||||||
"callsign": "HLL2",
|
|
||||||
"country": "KR",
|
|
||||||
"city": "Seoul",
|
|
||||||
"coordinates": [37.57, 126.98],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 3585, "description": "Night"},
|
|
||||||
{"khz": 5857.5, "description": "Primary"},
|
|
||||||
{"khz": 7433.5, "description": "Day"},
|
|
||||||
{"khz": 9165, "description": "Extended"},
|
|
||||||
{"khz": 13570, "description": "DX"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Taipei Met",
|
|
||||||
"callsign": "BMF",
|
|
||||||
"country": "TW",
|
|
||||||
"city": "Taipei",
|
|
||||||
"coordinates": [25.03, 121.57],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4616, "description": "Primary"},
|
|
||||||
{"khz": 8140, "description": "Day"},
|
|
||||||
{"khz": 13900, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Bangkok Met",
|
|
||||||
"callsign": "HSW64",
|
|
||||||
"country": "TH",
|
|
||||||
"city": "Bangkok",
|
|
||||||
"coordinates": [13.76, 100.50],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 7396.8, "description": "Primary"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Shanghai Met",
|
|
||||||
"callsign": "XSG",
|
|
||||||
"country": "CN",
|
|
||||||
"city": "Shanghai",
|
|
||||||
"coordinates": [31.23, 121.47],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4170, "description": "Night"},
|
|
||||||
{"khz": 8302, "description": "Primary"},
|
|
||||||
{"khz": 12382, "description": "Day"},
|
|
||||||
{"khz": 16559, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Guangzhou Radio",
|
|
||||||
"callsign": "XSQ",
|
|
||||||
"country": "CN",
|
|
||||||
"city": "Guangzhou",
|
|
||||||
"coordinates": [23.13, 113.26],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4199.8, "description": "Night"},
|
|
||||||
{"khz": 8412.5, "description": "Primary"},
|
|
||||||
{"khz": 12629.3, "description": "Day"},
|
|
||||||
{"khz": 16826.3, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Singapore Met",
|
|
||||||
"callsign": "9VF",
|
|
||||||
"country": "SG",
|
|
||||||
"city": "Singapore",
|
|
||||||
"coordinates": [1.35, 103.82],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 16035, "description": "Primary"},
|
|
||||||
{"khz": 17430, "description": "Alternate"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "New Delhi Met",
|
|
||||||
"callsign": "ATP",
|
|
||||||
"country": "IN",
|
|
||||||
"city": "New Delhi",
|
|
||||||
"coordinates": [28.61, 77.21],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 7405, "description": "Night"},
|
|
||||||
{"khz": 14842, "description": "Day"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Murmansk Met",
|
|
||||||
"callsign": "RBW",
|
|
||||||
"country": "RU",
|
|
||||||
"city": "Murmansk",
|
|
||||||
"coordinates": [68.97, 33.09],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 6445.5, "description": "Night"},
|
|
||||||
{"khz": 7907, "description": "Primary"},
|
|
||||||
{"khz": 8444, "description": "Day"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "08:00", "duration_min": 20, "content": "Ice Chart"},
|
|
||||||
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "14:30", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "St. Petersburg Met",
|
|
||||||
"callsign": "RDD78",
|
|
||||||
"country": "RU",
|
|
||||||
"city": "St. Petersburg",
|
|
||||||
"coordinates": [59.93, 30.32],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 2640, "description": "Night"},
|
|
||||||
{"khz": 4212, "description": "Primary"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Athens Met",
|
|
||||||
"callsign": "SVJ4",
|
|
||||||
"country": "GR",
|
|
||||||
"city": "Athens",
|
|
||||||
"coordinates": [37.97, 23.73],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4482.9, "description": "Night"},
|
|
||||||
{"khz": 8106.9, "description": "Primary"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
|
||||||
{"utc": "09:00", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Charleville Met",
|
|
||||||
"callsign": "VMC",
|
|
||||||
"country": "AU",
|
|
||||||
"city": "Charleville, QLD",
|
|
||||||
"coordinates": [-26.41, 146.24],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 2628, "description": "Night"},
|
|
||||||
{"khz": 5100, "description": "Primary"},
|
|
||||||
{"khz": 11030, "description": "Day"},
|
|
||||||
{"khz": 13920, "description": "Extended"},
|
|
||||||
{"khz": 20469, "description": "DX"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
|
||||||
{"utc": "03:00", "duration_min": 20, "content": "Prognosis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
|
||||||
{"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
|
||||||
{"utc": "19:00", "duration_min": 20, "content": "Prognosis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Wiluna Met",
|
|
||||||
"callsign": "VMW",
|
|
||||||
"country": "AU",
|
|
||||||
"city": "Wiluna, WA",
|
|
||||||
"coordinates": [-26.59, 120.23],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 5755, "description": "Night"},
|
|
||||||
{"khz": 7535, "description": "Primary"},
|
|
||||||
{"khz": 10555, "description": "Day"},
|
|
||||||
{"khz": 15615, "description": "Extended"},
|
|
||||||
{"khz": 18060, "description": "DX"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
|
||||||
{"utc": "11:00", "duration_min": 20, "content": "Prognosis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
|
||||||
{"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "NZ MetService",
|
|
||||||
"callsign": "ZKLF",
|
|
||||||
"country": "NZ",
|
|
||||||
"city": "Auckland",
|
|
||||||
"coordinates": [-36.85, 174.76],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 3247.4, "description": "Night"},
|
|
||||||
{"khz": 5807, "description": "Primary"},
|
|
||||||
{"khz": 9459, "description": "Day"},
|
|
||||||
{"khz": 13550.5, "description": "Extended"},
|
|
||||||
{"khz": 16340.1, "description": "DX"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CFH Halifax",
|
|
||||||
"callsign": "CFH",
|
|
||||||
"country": "CA",
|
|
||||||
"city": "Halifax, NS",
|
|
||||||
"coordinates": [44.65, -63.57],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4271, "description": "Night"},
|
|
||||||
{"khz": 6496.4, "description": "Primary"},
|
|
||||||
{"khz": 10536, "description": "Day"},
|
|
||||||
{"khz": 13510, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "03:00", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "22:22", "duration_min": 20, "content": "Ice Chart"},
|
|
||||||
{"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CCG Iqaluit",
|
|
||||||
"callsign": "VFF",
|
|
||||||
"country": "CA",
|
|
||||||
"city": "Iqaluit, NU",
|
|
||||||
"coordinates": [63.75, -68.52],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 3253, "description": "Night"},
|
|
||||||
{"khz": 7710, "description": "Day"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:10", "duration_min": 20, "content": "Ice Chart"},
|
|
||||||
{"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "07:00", "duration_min": 20, "content": "Ice Chart"},
|
|
||||||
{"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "11:00", "duration_min": 20, "content": "Ice Chart"},
|
|
||||||
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "23:30", "duration_min": 20, "content": "Ice Chart"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CCG Inuvik",
|
|
||||||
"callsign": "VFA",
|
|
||||||
"country": "CA",
|
|
||||||
"city": "Inuvik, NT",
|
|
||||||
"coordinates": [68.36, -133.72],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4292, "description": "Night"},
|
|
||||||
{"khz": 8457.8, "description": "Primary"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "02:00", "duration_min": 20, "content": "Ice Chart"},
|
|
||||||
{"utc": "16:30", "duration_min": 20, "content": "Ice Chart"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CCG Sydney",
|
|
||||||
"callsign": "VCO",
|
|
||||||
"country": "CA",
|
|
||||||
"city": "Sydney, NS",
|
|
||||||
"coordinates": [46.14, -60.19],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4416, "description": "Night"},
|
|
||||||
{"khz": 6915.1, "description": "Primary"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "11:42", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "23:31", "duration_min": 20, "content": "Surface Prog"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cape Naval",
|
|
||||||
"callsign": "ZSJ",
|
|
||||||
"country": "ZA",
|
|
||||||
"city": "Cape Town",
|
|
||||||
"coordinates": [-33.92, 18.42],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4014, "description": "Night"},
|
|
||||||
{"khz": 7508, "description": "Primary"},
|
|
||||||
{"khz": 13538, "description": "Day"},
|
|
||||||
{"khz": 18238, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "05:00", "duration_min": 20, "content": "Sea State"},
|
|
||||||
{"utc": "06:30", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "08:00", "duration_min": 20, "content": "Satellite Image"},
|
|
||||||
{"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "11:00", "duration_min": 20, "content": "Sea State"},
|
|
||||||
{"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "15:40", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Valparaiso Naval",
|
|
||||||
"callsign": "CBV",
|
|
||||||
"country": "CL",
|
|
||||||
"city": "Valparaiso",
|
|
||||||
"coordinates": [-33.05, -71.62],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4228, "description": "Night"},
|
|
||||||
{"khz": 8677, "description": "Primary"},
|
|
||||||
{"khz": 17146.4, "description": "Day"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "11:30", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "16:45", "duration_min": 20, "content": "Sea State"},
|
|
||||||
{"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "19:30", "duration_min": 20, "content": "Surface Prog"},
|
|
||||||
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "23:25", "duration_min": 20, "content": "Sea State"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Magallanes Naval",
|
|
||||||
"callsign": "CBM",
|
|
||||||
"country": "CL",
|
|
||||||
"city": "Punta Arenas",
|
|
||||||
"coordinates": [-53.16, -70.91],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 4322, "description": "Night"},
|
|
||||||
{"khz": 8696, "description": "Primary"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Rio de Janeiro Naval",
|
|
||||||
"callsign": "PWZ33",
|
|
||||||
"country": "BR",
|
|
||||||
"city": "Rio de Janeiro",
|
|
||||||
"coordinates": [-22.91, -43.17],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 12665, "description": "Primary"},
|
|
||||||
{"khz": 16978, "description": "Day"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Dakar Met",
|
|
||||||
"callsign": "6VU",
|
|
||||||
"country": "SN",
|
|
||||||
"city": "Dakar",
|
|
||||||
"coordinates": [14.69, -17.44],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 13667.5, "description": "Primary"},
|
|
||||||
{"khz": 19750, "description": "Day"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Misaki Fisheries",
|
|
||||||
"callsign": "JFC",
|
|
||||||
"country": "JP",
|
|
||||||
"city": "Miura",
|
|
||||||
"coordinates": [35.14, 139.62],
|
|
||||||
"frequencies": [
|
|
||||||
{"khz": 8616, "description": "Primary"},
|
|
||||||
{"khz": 13074, "description": "Day"},
|
|
||||||
{"khz": 17231, "description": "Extended"}
|
|
||||||
],
|
|
||||||
"ioc": 576,
|
|
||||||
"lpm": 120,
|
|
||||||
"schedule": [
|
|
||||||
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
|
||||||
{"utc": "06:00", "duration_min": 20, "content": "Current Chart"},
|
|
||||||
{"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"},
|
|
||||||
{"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,33 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker Compose configuration for easy deployment
|
# Docker Compose configuration for easy deployment
|
||||||
#
|
#
|
||||||
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
|
# 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):
|
# With ADS-B history (Postgres):
|
||||||
# docker compose --profile history up -d
|
# docker compose --profile history up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
intercept:
|
intercept:
|
||||||
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
|
||||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept
|
container_name: intercept
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
|
||||||
# - "5443:5443"
|
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
|
# Alternatively, use device mapping (see below)
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
# USB device mapping (alternative to privileged mode)
|
||||||
devices:
|
# devices:
|
||||||
- /dev/bus/usb:/dev/bus/usb
|
# - /dev/bus/usb:/dev/bus/usb
|
||||||
volumes:
|
# volumes:
|
||||||
# Persist decoded images and database across container rebuilds
|
# Persist data directory
|
||||||
- ./data:/app/data
|
# - ./data:/app/data
|
||||||
# Optional: mount logs directory
|
# Optional: mount logs directory
|
||||||
# - ./logs:/app/logs
|
# - ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-UTC}
|
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
# HTTPS support (auto-generates self-signed cert)
|
|
||||||
# - INTERCEPT_HTTPS=true
|
|
||||||
# - INTERCEPT_PORT=5443
|
|
||||||
# ADS-B history is disabled by default
|
# ADS-B history is disabled by default
|
||||||
# To enable, use: docker compose --profile history up -d
|
# To enable, use: docker compose --profile history up -d
|
||||||
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
@@ -52,9 +40,6 @@ services:
|
|||||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
# Shared observer location across modules
|
# Shared observer location across modules
|
||||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
# Default observer coordinates (set to your location to skip the GPS prompt)
|
|
||||||
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
|
||||||
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
|
||||||
# Network mode for WiFi scanning (requires host network)
|
# Network mode for WiFi scanning (requires host network)
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -68,33 +53,19 @@ services:
|
|||||||
# ADS-B history with Postgres persistence
|
# ADS-B history with Postgres persistence
|
||||||
# Enable with: docker compose --profile history up -d
|
# Enable with: docker compose --profile history up -d
|
||||||
intercept-history:
|
intercept-history:
|
||||||
# Same image/build fallback pattern as above
|
|
||||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept-history
|
container_name: intercept
|
||||||
profiles:
|
profiles:
|
||||||
- history
|
- history
|
||||||
depends_on:
|
depends_on:
|
||||||
- adsb_db
|
- adsb_db
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
|
||||||
# - "5443:5443"
|
|
||||||
# Privileged mode required for USB SDR device access
|
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
|
||||||
devices:
|
|
||||||
- /dev/bus/usb:/dev/bus/usb
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-UTC}
|
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
# HTTPS support (auto-generates self-signed cert)
|
|
||||||
# - INTERCEPT_HTTPS=true
|
|
||||||
# - INTERCEPT_PORT=5443
|
|
||||||
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
- INTERCEPT_ADSB_DB_PORT=5432
|
- INTERCEPT_ADSB_DB_PORT=5432
|
||||||
@@ -105,9 +76,6 @@ services:
|
|||||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
# Shared observer location across modules
|
# Shared observer location across modules
|
||||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
# Default observer coordinates (set to your location to skip the GPS prompt)
|
|
||||||
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
|
||||||
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||||
@@ -122,7 +90,6 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- history
|
- history
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-UTC}
|
|
||||||
- POSTGRES_DB=intercept_adsb
|
- POSTGRES_DB=intercept_adsb
|
||||||
- POSTGRES_USER=intercept
|
- POSTGRES_USER=intercept
|
||||||
- POSTGRES_PASSWORD=intercept
|
- POSTGRES_PASSWORD=intercept
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ The controller is the main Intercept application:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh # First-time setup (choose install profiles)
|
python app.py
|
||||||
sudo ./start.sh # Production server on http://localhost:5050
|
# Runs on http://localhost:5050
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure an Agent
|
### 2. Configure an Agent
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ Complete feature list for all modules.
|
|||||||
- **Doorbells, remotes, and IoT devices**
|
- **Doorbells, remotes, and IoT devices**
|
||||||
- **Smart meters** and utility monitors
|
- **Smart meters** and utility monitors
|
||||||
|
|
||||||
## Sub-GHz Analyzer
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
|
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||||
- **Protocol decoding** - identify and decode common Sub-GHz protocols
|
- **Full-screen dashboard** - dedicated popout with interactive map
|
||||||
- **Signal replay/transmit** capabilities for authorized testing
|
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||||
- **Wideband spectrum analysis** with real-time visualization
|
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
||||||
- **I/Q capture** - record raw samples for offline analysis
|
- **Navigation data** - speed, course, heading, rate of turn
|
||||||
|
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
||||||
|
- **Vessel dimensions** - length, width, draught
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
## Spy Stations (Number Stations)
|
## Spy Stations (Number Stations)
|
||||||
|
|
||||||
@@ -81,124 +84,6 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||||
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||||
|
|
||||||
## ACARS Messaging
|
|
||||||
|
|
||||||
- **Real-time ACARS decoding** via acarsdec
|
|
||||||
- **Aircraft datalink messages** - operational, weather, and position reports
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
- **Message filtering** - filter by message type, flight, or registration
|
|
||||||
|
|
||||||
## VDL2 (VHF Data Link Mode 2)
|
|
||||||
|
|
||||||
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
|
|
||||||
- **ACARS-over-AVLC** message capture with full frame parsing
|
|
||||||
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
|
|
||||||
- **AVLC frame details** - source/destination addresses, frame type, command/response
|
|
||||||
- **Raw JSON inspection** - expandable raw message data for each frame
|
|
||||||
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
- **CSV/JSON export** - export captured messages for offline analysis
|
|
||||||
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
|
||||||
|
|
||||||
## CW/Morse Code Decoder
|
|
||||||
|
|
||||||
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
|
|
||||||
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
|
|
||||||
- **HF frequency presets** for amateur CW bands (160m-10m)
|
|
||||||
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
|
|
||||||
- **Real-time character and word output** with WPM estimation
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
|
|
||||||
## WeFax (Weather Fax)
|
|
||||||
|
|
||||||
- **HF weather fax reception** from marine and meteorological broadcast stations
|
|
||||||
- **Broadcast timeline** with scheduled transmission times by station
|
|
||||||
- **Auto-scheduler** for unattended capture of scheduled broadcasts
|
|
||||||
- **Image gallery** with timestamped decoded weather charts
|
|
||||||
- **Station presets** for major WeFax broadcasters worldwide
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
|
|
||||||
## 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
|
|
||||||
- **Waterfall spectrum display** for visual signal identification
|
|
||||||
- **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
|
|
||||||
|
|
||||||
## ISS SSTV
|
|
||||||
|
|
||||||
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
|
|
||||||
- **Real-time ISS tracking** with world map and pass predictions
|
|
||||||
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
|
|
||||||
- **Next pass countdown** - time remaining until ISS is overhead
|
|
||||||
- **Image gallery** with timestamped decoded imagery
|
|
||||||
- **TLE updates** - fetch latest ISS orbital elements
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
|
|
||||||
## HF SSTV
|
|
||||||
|
|
||||||
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
|
||||||
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
|
|
||||||
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
|
|
||||||
- **Image gallery** with decoded transmissions
|
|
||||||
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Space Weather
|
|
||||||
|
|
||||||
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
|
||||||
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
|
||||||
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
|
||||||
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
|
||||||
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
|
||||||
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
|
||||||
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
|
||||||
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
|
||||||
- **Aurora forecast** - OVATION aurora oval visualization
|
|
||||||
- **SWPC alerts** - Real-time space weather alerts and warnings
|
|
||||||
- **Active solar regions** - Current sunspot region data with location and area
|
|
||||||
- **Auto-refresh** - 5-minute polling with manual refresh option
|
|
||||||
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
|
||||||
|
|
||||||
## Radiosonde Weather Balloon Tracking
|
|
||||||
|
|
||||||
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
|
|
||||||
- **Frequency presets** for common radiosonde bands
|
|
||||||
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
|
|
||||||
- **Interactive map** with balloon trajectory and burst point prediction
|
|
||||||
- **Station location** with configurable observer position
|
|
||||||
- **Distance tracking** - real-time distance-to-balloon calculation
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
@@ -246,80 +131,6 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **Proximity radar** visualization
|
- **Proximity radar** visualization
|
||||||
- **Device type breakdown** chart
|
- **Device type breakdown** chart
|
||||||
|
|
||||||
## BT Locate (SAR Bluetooth Device Location)
|
|
||||||
|
|
||||||
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
|
||||||
|
|
||||||
### Core Features
|
|
||||||
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
|
||||||
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
|
||||||
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
|
||||||
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
|
||||||
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
|
||||||
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
|
||||||
- **Distance estimation** - Log-distance path loss model with environment presets
|
|
||||||
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
|
||||||
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
|
||||||
|
|
||||||
### Environment Presets
|
|
||||||
- **Open Field** (n=2.0) - Free space path loss
|
|
||||||
- **Outdoor** (n=2.2) - Typical outdoor environment
|
|
||||||
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
|
||||||
|
|
||||||
### Map & Trail
|
|
||||||
- Interactive Leaflet map with GPS trail visualization
|
|
||||||
- Trail points color-coded by proximity band
|
|
||||||
- Polyline connecting detection points for path visualization
|
|
||||||
- Supports user-configured tile providers
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
- Bluetooth adapter (built-in or USB)
|
|
||||||
- GPS receiver (optional, falls back to manual coordinates)
|
|
||||||
|
|
||||||
## WiFi Locate
|
|
||||||
|
|
||||||
Locate a WiFi access point by BSSID using real-time signal strength tracking.
|
|
||||||
|
|
||||||
### Core Features
|
|
||||||
- **Target by BSSID** - Enter any MAC address or hand off from the WiFi scanner
|
|
||||||
- **Real-time signal meter** - Large dBm display with color-coded strength (good/medium/weak)
|
|
||||||
- **20-segment signal bar** - Visual proximity indicator with red/yellow/green segments
|
|
||||||
- **RSSI history chart** - Canvas sparkline showing signal trend over time
|
|
||||||
- **Distance estimation** - Log-distance path loss model with configurable environment presets
|
|
||||||
- **Audio proximity alerts** - Web Audio API tones that increase in pitch and frequency as signal strengthens
|
|
||||||
- **Signal lost detection** - 30-second timeout with visual overlay when target disappears
|
|
||||||
- **Hand-off from WiFi mode** - One-click transfer from WiFi detail drawer to WiFi Locate
|
|
||||||
- **Stats tracking** - Current, min, max, and average RSSI across session
|
|
||||||
|
|
||||||
### Environment Presets
|
|
||||||
- **Open Field** (n=2.0) - Free space path loss
|
|
||||||
- **Outdoor** (n=2.8) - Typical outdoor environment (default)
|
|
||||||
- **Indoor** (n=3.5) - Indoor with walls and obstacles
|
|
||||||
|
|
||||||
### Mode Transition
|
|
||||||
- WiFi scan is preserved when switching between WiFi and WiFi Locate modes
|
|
||||||
- Deep scan auto-starts if not already running
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
- WiFi adapter capable of monitor mode
|
|
||||||
- aircrack-ng suite for deep scanning
|
|
||||||
|
|
||||||
## GPS Mode
|
|
||||||
|
|
||||||
Real-time GPS position tracking with live map visualization.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
|
||||||
- **Interactive map** - Current position on Leaflet map with track history
|
|
||||||
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
|
||||||
- **Satellite info** - Number of satellites in view and fix quality
|
|
||||||
- **Track recording** - Record GPS tracks with export capability
|
|
||||||
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
- USB GPS receiver connected via gpsd
|
|
||||||
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
|
||||||
|
|
||||||
## TSCM Counter-Surveillance Mode
|
## TSCM Counter-Surveillance Mode
|
||||||
|
|
||||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||||
@@ -327,7 +138,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
|||||||
### Wireless Sweep Features
|
### Wireless Sweep Features
|
||||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||||
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
|
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
|
||||||
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||||
|
|
||||||
@@ -426,20 +237,10 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
|||||||
- **Redundancy** - Multiple nodes for reliable coverage
|
- **Redundancy** - Multiple nodes for reliable coverage
|
||||||
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||||
|
|
||||||
## System Health
|
|
||||||
|
|
||||||
- **Telemetry dashboard** with real-time system metrics
|
|
||||||
- **Process monitoring** for all running SDR tools and decoders
|
|
||||||
- **CPU, memory, and disk usage** tracking
|
|
||||||
- **SDR device status** overview
|
|
||||||
- **No SDR required** - monitors system health independently
|
|
||||||
|
|
||||||
## User Interface
|
## User Interface
|
||||||
|
|
||||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||||
- **UTC clock** - always visible in header for time-critical operations
|
- **UTC clock** - always visible in header for time-critical operations
|
||||||
- **SSE connection status indicator** - real-time connection state with SSEManager and exponential backoff reconnection
|
|
||||||
- **Accessibility** - aria-labels, form label associations, keyboard list navigation, and destructive action confirmation modals
|
|
||||||
- **Active mode indicator** - shows current mode with pulse animation
|
- **Active mode indicator** - shows current mode with pulse animation
|
||||||
- **Collapsible sections** - click any header to collapse/expand
|
- **Collapsible sections** - click any header to collapse/expand
|
||||||
- **Panel styling** - gradient backgrounds with indicator dots
|
- **Panel styling** - gradient backgrounds with indicator dots
|
||||||
@@ -496,19 +297,14 @@ The settings modal shows availability status for each bundled asset:
|
|||||||
## General
|
## General
|
||||||
|
|
||||||
- **Web-based interface** - no desktop app needed
|
- **Web-based interface** - no desktop app needed
|
||||||
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
|
|
||||||
- **Live message streaming** via Server-Sent Events (SSE)
|
- **Live message streaming** via Server-Sent Events (SSE)
|
||||||
- **Audio alerts** with mute toggle
|
- **Audio alerts** with mute toggle
|
||||||
- **Message export** to CSV/JSON
|
- **Message export** to CSV/JSON
|
||||||
- **Signal activity meter** and waterfall display
|
- **Signal activity meter** and waterfall display
|
||||||
- **Message logging** to file with timestamps
|
- **Message logging** to file with timestamps
|
||||||
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
|
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
|
||||||
- **Voice alerts** for configurable event notifications across modes
|
|
||||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
|
||||||
- **Automatic device detection** across all supported hardware
|
- **Automatic device detection** across all supported hardware
|
||||||
- **Hardware-specific validation** - frequency/gain ranges per device type
|
- **Hardware-specific validation** - frequency/gain ranges per device type
|
||||||
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
|
|
||||||
- **Native Homebrew detection** for Apple Silicon tool paths
|
|
||||||
- **Configurable gain and PPM correction**
|
- **Configurable gain and PPM correction**
|
||||||
- **Device intelligence** dashboard with tracking
|
- **Device intelligence** dashboard with tracking
|
||||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||||
|
|||||||
@@ -14,39 +14,7 @@ INTERCEPT automatically detects connected devices.
|
|||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
### Recommended: Use the Setup Script
|
### macOS (Homebrew)
|
||||||
|
|
||||||
The setup script provides an interactive menu with install profiles for selective installation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/smittix/intercept.git
|
|
||||||
cd intercept
|
|
||||||
./setup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
On first run, a guided wizard walks you through profile selection:
|
|
||||||
|
|
||||||
| Profile | What it installs |
|
|
||||||
|---------|-----------------|
|
|
||||||
| Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
|
|
||||||
| Maritime & Radio | AIS-catcher, direwolf |
|
|
||||||
| Weather & Space | SatDump, radiosonde_auto_rx |
|
|
||||||
| RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
|
|
||||||
| Full SIGINT | All of the above |
|
|
||||||
|
|
||||||
For headless/CI installs:
|
|
||||||
```bash
|
|
||||||
./setup.sh --non-interactive # Install everything
|
|
||||||
./setup.sh --profile=core,maritime # Install specific profiles
|
|
||||||
```
|
|
||||||
|
|
||||||
After installation, use the menu to manage your setup:
|
|
||||||
```bash
|
|
||||||
./setup.sh # Opens interactive menu
|
|
||||||
./setup.sh --health-check # Verify installation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Install: macOS (Homebrew)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Homebrew if needed
|
# Install Homebrew if needed
|
||||||
@@ -68,7 +36,7 @@ brew install soapysdr limesuite soapylms7
|
|||||||
brew install hackrf soapyhackrf
|
brew install hackrf soapyhackrf
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
|
### Debian / Ubuntu / Raspberry Pi OS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Update package lists
|
# Update package lists
|
||||||
@@ -126,126 +94,6 @@ sudo modprobe -r dvb_usb_rtl28xxu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Multiple RTL-SDR Dongles
|
|
||||||
|
|
||||||
If you're running two (or more) RTL-SDR dongles on the same machine, they ship with the same default serial number so Linux can't tell them apart reliably. Follow these steps to give each a unique identity.
|
|
||||||
|
|
||||||
### Step 1: Blacklist the DVB-T driver
|
|
||||||
|
|
||||||
Already covered above, but make sure this is done first — the kernel's DVB driver will grab the dongles before librtlsdr can:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
|
||||||
sudo modprobe -r dvb_usb_rtl28xxu
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Burn unique serial numbers
|
|
||||||
|
|
||||||
Each dongle has an EEPROM that stores a serial number. By default they're all `00000001`. You need to give each one a unique serial.
|
|
||||||
|
|
||||||
**Plug in only the first dongle**, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rtl_eeprom -d 0 -s 00000001
|
|
||||||
```
|
|
||||||
|
|
||||||
**Unplug it, plug in the second dongle**, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rtl_eeprom -d 0 -s 00000002
|
|
||||||
```
|
|
||||||
|
|
||||||
> Pick any 8-digit hex serials you like. The `-d 0` means "device index 0" (the only one plugged in).
|
|
||||||
|
|
||||||
Unplug and replug both dongles after writing.
|
|
||||||
|
|
||||||
### Step 3: Verify
|
|
||||||
|
|
||||||
With both plugged in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rtl_test -t
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see:
|
|
||||||
|
|
||||||
```
|
|
||||||
0: Realtek, RTL2838UHIDIR, SN: 00000001
|
|
||||||
1: Realtek, RTL2838UHIDIR, SN: 00000002
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tip:** If you don't know which physical dongle has which serial, unplug one and run `rtl_test -t` — the one still detected is the one still plugged in.
|
|
||||||
|
|
||||||
### Step 4: Udev rules with stable symlinks
|
|
||||||
|
|
||||||
Create rules that give each dongle a persistent name based on its serial:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
|
||||||
# RTL-SDR dongles - permissions and stable symlinks by serial
|
|
||||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", MODE="0666"
|
|
||||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2832", MODE="0666"
|
|
||||||
|
|
||||||
# Symlinks by serial — change names/serials to match your hardware
|
|
||||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000001", SYMLINK+="sdr-dongle1"
|
|
||||||
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000002", SYMLINK+="sdr-dongle2"
|
|
||||||
EOF'
|
|
||||||
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
sudo udevadm trigger
|
|
||||||
```
|
|
||||||
|
|
||||||
After replugging, you'll have `/dev/sdr-dongle1` and `/dev/sdr-dongle2`.
|
|
||||||
|
|
||||||
### Step 5: USB power (Raspberry Pi)
|
|
||||||
|
|
||||||
Two dongles can draw more current than the Pi allows by default:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In /boot/firmware/config.txt, add:
|
|
||||||
usb_max_current_enable=1
|
|
||||||
```
|
|
||||||
|
|
||||||
Disable USB autosuspend so dongles don't get powered off:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In /etc/default/grub or kernel cmdline, add:
|
|
||||||
usbcore.autosuspend=-1
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via udev:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend}="-1"' | \
|
|
||||||
sudo tee /etc/udev/rules.d/50-usb-autosuspend.rules
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Docker access
|
|
||||||
|
|
||||||
Your `docker-compose.yml` needs privileged mode and USB passthrough:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
intercept:
|
|
||||||
privileged: true
|
|
||||||
volumes:
|
|
||||||
- /dev/bus/usb:/dev/bus/usb
|
|
||||||
```
|
|
||||||
|
|
||||||
INTERCEPT auto-detects both dongles inside the container via `rtl_test -t` and addresses them by device index (`-d 0`, `-d 1`).
|
|
||||||
|
|
||||||
### Quick reference
|
|
||||||
|
|
||||||
| Step | What | Why |
|
|
||||||
|------|------|-----|
|
|
||||||
| Blacklist DVB | `/etc/modprobe.d/blacklist-rtl.conf` | Kernel won't steal the dongles |
|
|
||||||
| Burn serials | `rtl_eeprom -d 0 -s <serial>` | Unique identity per dongle |
|
|
||||||
| Udev rules | `/etc/udev/rules.d/20-rtlsdr.rules` | Permissions + stable `/dev/sdr-*` names |
|
|
||||||
| USB power | `config.txt` + autosuspend off | Enough current for two dongles on a Pi |
|
|
||||||
| Docker | `privileged: true` + USB volume | Container sees both dongles |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verify Installation
|
## Verify Installation
|
||||||
|
|
||||||
### Check dependencies
|
### Check dependencies
|
||||||
@@ -271,19 +119,11 @@ SoapySDRUtil --find
|
|||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The setup wizard automatically:
|
This automatically:
|
||||||
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
|
- Detects your OS
|
||||||
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
|
- Creates a virtual environment if needed (for PEP 668 systems)
|
||||||
- Creates a virtual environment with system site-packages
|
- Installs Python dependencies
|
||||||
- Installs Python dependencies (core + optional)
|
- Checks for required tools
|
||||||
- Runs a health check to verify everything works
|
|
||||||
|
|
||||||
After initial setup, use the menu to manage your environment:
|
|
||||||
- **Install / Add Modules** — add tools you didn't install initially
|
|
||||||
- **System Health Check** — verify all tools and dependencies
|
|
||||||
- **Environment Configurator** — set `INTERCEPT_*` variables interactively
|
|
||||||
- **Update Tools** — rebuild source-built tools (dump1090, SatDump, etc.)
|
|
||||||
- **View Status** — see what's installed at a glance
|
|
||||||
|
|
||||||
### Manual setup
|
### Manual setup
|
||||||
```bash
|
```bash
|
||||||
@@ -299,13 +139,10 @@ pip install -r requirements.txt
|
|||||||
After installation:
|
After installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./start.sh
|
sudo -E venv/bin/python intercept.py
|
||||||
|
|
||||||
# Custom port
|
# Custom port
|
||||||
sudo ./start.sh -p 8080
|
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||||
|
|
||||||
# HTTPS
|
|
||||||
sudo ./start.sh --https
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://localhost:5050** in your browser.
|
Open **http://localhost:5050** in your browser.
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
|
|||||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
|
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||||
```bash
|
```bash
|
||||||
sudo ./start.sh -H 127.0.0.1
|
export INTERCEPT_HOST=127.0.0.1
|
||||||
|
python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
|||||||
# Then create venv with system packages
|
# Then create venv with system packages
|
||||||
python3 -m venv --system-site-packages venv
|
python3 -m venv --system-site-packages venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
sudo ./start.sh
|
sudo venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### "error: externally-managed-environment" (pip blocked)
|
### "error: externally-managed-environment" (pip blocked)
|
||||||
@@ -61,21 +61,18 @@ sudo apt install python3.11 python3.11-venv python3-pip
|
|||||||
python3.11 -m venv venv
|
python3.11 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
sudo ./start.sh
|
sudo venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative: Use the setup script
|
### Alternative: Use the setup script
|
||||||
|
|
||||||
The setup script handles all installation automatically, including apt packages and source builds:
|
The setup script handles all installation automatically, including apt packages:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./setup.sh # Interactive wizard (first run) or menu
|
chmod +x setup.sh
|
||||||
./setup.sh --non-interactive # Headless full install
|
./setup.sh
|
||||||
./setup.sh --health-check # Diagnose installation issues
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The setup menu also includes a **System Health Check** (option 2) that verifies all tools, SDR devices, ports, permissions, and Python packages — useful for diagnosing installation problems.
|
|
||||||
|
|
||||||
### "pip: command not found"
|
### "pip: command not found"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -339,7 +336,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
|||||||
|
|
||||||
Run INTERCEPT with sudo:
|
Run INTERCEPT with sudo:
|
||||||
```bash
|
```bash
|
||||||
sudo ./start.sh
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interface not found after enabling monitor mode
|
### Interface not found after enabling monitor mode
|
||||||
@@ -376,14 +373,7 @@ sudo usermod -a -G bluetooth $USER
|
|||||||
|
|
||||||
### Cannot install dump1090 in Debian (ADS-B mode)
|
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||||
|
|
||||||
On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
|
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
|
||||||
|
|
||||||
```bash
|
|
||||||
./setup.sh # Select Core SIGINT profile, or
|
|
||||||
./setup.sh --profile=core # Install core tools including dump1090
|
|
||||||
```
|
|
||||||
|
|
||||||
The setup menu's **Install / Add Modules** option also lets you install dump1090 individually via the Custom tool checklist.
|
|
||||||
|
|
||||||
### No aircraft appearing (ADS-B mode)
|
### No aircraft appearing (ADS-B mode)
|
||||||
|
|
||||||
|
|||||||
@@ -206,23 +206,14 @@ Extended base for full-screen dashboards (maps, visualizations).
|
|||||||
| `listening` | Listening post |
|
| `listening` | Listening post |
|
||||||
| `spystations` | Spy stations |
|
| `spystations` | Spy stations |
|
||||||
| `meshtastic` | Mesh networking |
|
| `meshtastic` | Mesh networking |
|
||||||
| `weathersat` | Weather satellites |
|
|
||||||
| `sstv_general` | HF SSTV |
|
|
||||||
| `gps` | GPS tracking |
|
|
||||||
| `websdr` | WebSDR |
|
|
||||||
| `subghz` | Sub-GHz analyzer |
|
|
||||||
| `bt_locate` | BT Locate |
|
|
||||||
| `wifi_locate` | WiFi Locate |
|
|
||||||
| `analytics` | Analytics dashboard |
|
|
||||||
| `spaceweather` | Space weather |
|
|
||||||
### Navigation Groups
|
### Navigation Groups
|
||||||
|
|
||||||
The navigation is organized into groups:
|
The navigation is organized into groups:
|
||||||
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||||
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
- **Wireless**: WiFi, Bluetooth
|
||||||
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
- **Security**: TSCM
|
||||||
- **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
|
- **Space**: Satellite, ISS SSTV
|
||||||
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -57,48 +57,6 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- Samsung SmartTag
|
- Samsung SmartTag
|
||||||
- Chipolo
|
- Chipolo
|
||||||
|
|
||||||
## Sub-GHz Analyzer
|
|
||||||
|
|
||||||
1. **Connect HackRF** - Plug in your HackRF One device
|
|
||||||
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
|
|
||||||
3. **Start Capture** - Click "Start Capture" to begin signal analysis
|
|
||||||
4. **View Spectrum** - Real-time spectrum visualization of the selected band
|
|
||||||
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
|
|
||||||
|
|
||||||
### Supported Protocols
|
|
||||||
|
|
||||||
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
|
||||||
|
|
||||||
## VDL2 (Aircraft Datalink)
|
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
|
||||||
2. **Select Device** - Choose your SDR device
|
|
||||||
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
|
|
||||||
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
|
|
||||||
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
|
|
||||||
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
|
|
||||||
7. **Export** - Use CSV or JSON export buttons to save captured messages
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- VDL2 is most active near airports and along flight corridors
|
|
||||||
- Multiple frequencies can be monitored simultaneously for better coverage
|
|
||||||
- VDL2 data is also accessible from the ADS-B dashboard
|
|
||||||
|
|
||||||
## Listening Post
|
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
|
||||||
2. **Set Frequency Range** - Define start and end frequencies for scanning
|
|
||||||
3. **Start Scanning** - Click "Start Scan" for wideband sweep
|
|
||||||
4. **View Signals** - Discovered signals are listed with frequency and SNR
|
|
||||||
5. **Tune In** - Click a signal to tune the audio demodulator
|
|
||||||
6. **Listen** - Real-time audio plays in your browser
|
|
||||||
|
|
||||||
### Demodulation Modes
|
|
||||||
|
|
||||||
- **FM** - Narrowband and wideband FM
|
|
||||||
- **SSB** - Upper and lower sideband for amateur radio and shortwave
|
|
||||||
|
|
||||||
## Aircraft Mode (ADS-B)
|
## Aircraft Mode (ADS-B)
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||||
@@ -126,23 +84,6 @@ The system highlights aircraft transmitting emergency squawks:
|
|||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
## ACARS Messaging
|
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
|
||||||
2. **Select Device** - Choose your SDR device
|
|
||||||
3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
|
|
||||||
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
|
|
||||||
5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
|
|
||||||
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
|
|
||||||
7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- A vertical polarization antenna works best for ACARS
|
|
||||||
- Quarter-wave dipole: 57 cm per element at 130 MHz
|
|
||||||
- Stock SDR antenna may work at close range near airports
|
|
||||||
- Outdoor placement with clear sky view significantly improves reception
|
|
||||||
|
|
||||||
## ADS-B History (Optional)
|
## ADS-B History (Optional)
|
||||||
|
|
||||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||||
@@ -172,7 +113,7 @@ Set the following environment variables (Docker recommended):
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
sudo ./start.sh
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
@@ -203,8 +144,6 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
|||||||
3. View aircraft history and timelines
|
3. View aircraft history and timelines
|
||||||
4. Stop tracking when desired (session history is recorded)
|
4. Stop tracking when desired (session history is recorded)
|
||||||
|
|
||||||
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
|
|
||||||
|
|
||||||
## Satellite Mode
|
## Satellite Mode
|
||||||
|
|
||||||
1. **Set Location** - Choose location source:
|
1. **Set Location** - Choose location source:
|
||||||
@@ -224,269 +163,6 @@ If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HIST
|
|||||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||||
4. Select satellites to add
|
4. Select satellites to add
|
||||||
|
|
||||||
## Weather Satellites
|
|
||||||
|
|
||||||
1. **Set Location** - Enter observer coordinates or use GPS
|
|
||||||
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
|
|
||||||
3. **View Passes** - Upcoming passes shown with polar plot and ground track
|
|
||||||
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
|
|
||||||
5. **View Images** - Decoded imagery appears in the gallery
|
|
||||||
|
|
||||||
### Auto-Scheduler
|
|
||||||
|
|
||||||
Enable the auto-scheduler to automatically capture passes:
|
|
||||||
- Calculates upcoming NOAA and Meteor passes for your location
|
|
||||||
- Starts SatDump at the correct time and frequency
|
|
||||||
- Decoded images are saved with timestamps
|
|
||||||
|
|
||||||
## Space Weather
|
|
||||||
|
|
||||||
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
|
|
||||||
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
|
|
||||||
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
|
|
||||||
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
|
|
||||||
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
|
|
||||||
6. **Alerts** - Review current SWPC space weather alerts and warnings
|
|
||||||
7. **Active Regions** - View solar active region data (number, location, area)
|
|
||||||
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
|
|
||||||
- Check HF band conditions before operating on shortwave frequencies
|
|
||||||
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
|
|
||||||
- D-RAP maps show where HF absorption is highest — useful for path planning
|
|
||||||
- Solar imagery updates approximately every 15 minutes from NASA SDO
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## WebSDR
|
|
||||||
|
|
||||||
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
|
|
||||||
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
|
|
||||||
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
|
|
||||||
4. **Select Receiver** - Click a receiver from the list to connect
|
|
||||||
5. **Listen** - Audio streams in real-time via WebSocket
|
|
||||||
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
|
|
||||||
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- Requires an internet connection to access the KiwiSDR network
|
|
||||||
- Receiver list is cached for 1 hour to reduce API load
|
|
||||||
- Receivers are sorted by distance from your location
|
|
||||||
- Integrated spy station presets allow quick tuning to SIGINT targets
|
|
||||||
|
|
||||||
## ISS SSTV
|
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
|
||||||
2. **Select Device** - Choose your SDR device
|
|
||||||
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
|
|
||||||
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
|
|
||||||
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
|
|
||||||
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
|
|
||||||
7. **Start Decoding** - Click "Start" to begin SSTV reception
|
|
||||||
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- A V-dipole or better antenna is required (stock antenna will not work)
|
|
||||||
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
|
|
||||||
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
|
|
||||||
- Best passes have elevation > 30 degrees above horizon
|
|
||||||
- Doppler shift tracking dramatically improves reception quality
|
|
||||||
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
|
|
||||||
- Outdoor antenna placement with clear sky view is essential
|
|
||||||
|
|
||||||
## HF SSTV
|
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
|
||||||
2. **Select Device** - Choose your SDR device
|
|
||||||
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
|
|
||||||
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
|
|
||||||
5. **Start Decoding** - Click "Start" to begin SSTV reception
|
|
||||||
6. **View Images** - Decoded amateur radio images appear in the gallery
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
|
|
||||||
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
|
|
||||||
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
|
|
||||||
- Weekend activity peaks on most HF bands
|
|
||||||
- Amateur license is not required to receive (listen-only)
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## WiFi Locate Mode
|
|
||||||
|
|
||||||
1. **Set Target** - Enter a BSSID (MAC address) in AA:BB:CC:DD:EE:FF format, or hand off from WiFi mode
|
|
||||||
2. **Choose Environment** - Select the RF environment preset:
|
|
||||||
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
|
||||||
- **Outdoor** (n=2.8) - Default, works well in most outdoor settings
|
|
||||||
- **Indoor** (n=3.5) - For buildings with walls and obstacles
|
|
||||||
3. **Start Locate** - Click "Start Locate" to begin tracking
|
|
||||||
4. **Monitor Signal** - The HUD shows:
|
|
||||||
- Large dBm reading with color coding (green/yellow/red)
|
|
||||||
- 20-segment signal bar for quick visual reference
|
|
||||||
- Estimated distance based on path loss model
|
|
||||||
- RSSI history chart for trend analysis
|
|
||||||
- Current/min/max/average statistics
|
|
||||||
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
|
||||||
6. **Audio Alerts** - Enable audio for proximity tones that speed up as signal strengthens
|
|
||||||
|
|
||||||
### Hand-off from WiFi Mode
|
|
||||||
|
|
||||||
1. Open WiFi scanning mode and start a deep scan
|
|
||||||
2. Click any network to open the detail drawer
|
|
||||||
3. Click the "Locate" button in the drawer header
|
|
||||||
4. WiFi Locate opens with the BSSID and SSID pre-filled
|
|
||||||
5. Click "Start Locate" to begin tracking
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- Deep scan is required for continuous RSSI updates — WiFi Locate auto-starts it if needed
|
|
||||||
- The WiFi scan is preserved when switching between WiFi and WiFi Locate modes
|
|
||||||
- Signal lost overlay appears after 30 seconds without an update from the target
|
|
||||||
- The distance estimate is approximate — environment preset significantly affects accuracy
|
|
||||||
- Indoor environments with walls attenuate signal more than open field
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## TSCM (Counter-Surveillance)
|
|
||||||
|
|
||||||
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
|
|
||||||
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
|
|
||||||
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
|
|
||||||
4. **Start Sweep** - Click "Start Sweep" to begin scanning
|
|
||||||
5. **Review Results** - Detected devices are classified and scored by threat level
|
|
||||||
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
|
|
||||||
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
|
|
||||||
|
|
||||||
### Threat Levels
|
|
||||||
|
|
||||||
- **Informational (0-2)** - Known or expected devices
|
|
||||||
- **Needs Review (3-5)** - Unusual devices requiring assessment
|
|
||||||
- **High Interest (6+)** - Multiple indicators warrant investigation
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- Record a baseline in a known clean environment before conducting sweeps
|
|
||||||
- Use the meeting window feature to flag new RF signatures during sensitive periods
|
|
||||||
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
|
||||||
- Threat detection uses a database of 47K+ known tracker fingerprints
|
|
||||||
|
|
||||||
## Spy Stations
|
|
||||||
|
|
||||||
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
|
||||||
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
|
|
||||||
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
|
|
||||||
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
|
|
||||||
5. **View Details** - Click "Details" on a station card for full information
|
|
||||||
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- Data sourced from priyom.org (non-profit monitoring community)
|
|
||||||
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
|
|
||||||
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
|
|
||||||
- Legal to monitor in most countries (check local regulations)
|
|
||||||
- No decryption or content decoding is included — this is a reference database
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Offline Mode
|
|
||||||
|
|
||||||
1. **Open Settings** - Click the gear icon in the navigation bar
|
|
||||||
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
|
|
||||||
3. **Configure Sources** - Switch assets and fonts from CDN to local
|
|
||||||
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
|
|
||||||
5. **Check Assets** - Click "Check Assets" to verify all local files are present
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
|
|
||||||
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
|
|
||||||
- Assets are stored in the `static/vendor/` directory
|
|
||||||
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
|
|
||||||
- Missing assets fail gracefully with console warnings
|
|
||||||
- Useful for air-gapped environments, field deployments, or reducing latency
|
|
||||||
|
|
||||||
## Remote Agents (Distributed SIGINT)
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
@@ -551,28 +227,10 @@ INTERCEPT can be configured via environment variables:
|
|||||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||||
|
|
||||||
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
|
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
||||||
|
|
||||||
## Command-line Options
|
## Command-line Options
|
||||||
|
|
||||||
### Production server (recommended)
|
|
||||||
|
|
||||||
```
|
|
||||||
sudo ./start.sh --help
|
|
||||||
|
|
||||||
-p, --port PORT Port to listen on (default: 5050)
|
|
||||||
-H, --host HOST Host to bind to (default: 0.0.0.0)
|
|
||||||
-d, --debug Run in debug mode (Flask dev server)
|
|
||||||
--https Enable HTTPS with self-signed certificate
|
|
||||||
--check-deps Check dependencies and exit
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
|
|
||||||
|
|
||||||
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
|
|
||||||
|
|
||||||
### Development server
|
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 intercept.py --help
|
python3 intercept.py --help
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 790 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 853 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 876 KiB |
|
Before Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 886 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -11,10 +11,9 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="bg-canvas"></canvas>
|
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<a href="#" class="nav-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</a>
|
<a href="#" class="nav-logo">iNTERCEPT</a>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#features">Features</a>
|
<a href="#features">Features</a>
|
||||||
<a href="#screenshots">Screenshots</a>
|
<a href="#screenshots">Screenshots</a>
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-badge">Open Source SIGINT Platform</div>
|
<div class="hero-badge">Open Source SIGINT Platform</div>
|
||||||
<h1><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</h1>
|
<h1>iNTERCEPT</h1>
|
||||||
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
|
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
|
||||||
<div class="hero-buttons">
|
<div class="hero-buttons">
|
||||||
<a href="#installation" class="btn btn-primary">Get Started</a>
|
<a href="#installation" class="btn btn-primary">Get Started</a>
|
||||||
@@ -36,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">34</span>
|
<span class="stat-value">15+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -59,174 +58,97 @@
|
|||||||
<h2>Capabilities</h2>
|
<h2>Capabilities</h2>
|
||||||
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||||
|
|
||||||
<div class="carousel-filters">
|
<div class="features-grid">
|
||||||
<button class="filter-btn active" data-filter="all">All</button>
|
<div class="feature-card">
|
||||||
<button class="filter-btn" data-filter="signals">Signals</button>
|
<div class="feature-icon">📟</div>
|
||||||
<button class="filter-btn" data-filter="tracking">Tracking</button>
|
|
||||||
<button class="filter-btn" data-filter="space">Space</button>
|
|
||||||
<button class="filter-btn" data-filter="wireless">Wireless</button>
|
|
||||||
<button class="filter-btn" data-filter="intel">Intel</button>
|
|
||||||
<button class="filter-btn" data-filter="platform">Platform</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="carousel-wrapper">
|
|
||||||
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
|
||||||
<div class="carousel-track">
|
|
||||||
<div class="feature-card" data-category="signals">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
|
||||||
<h3>Pager Decoding</h3>
|
<h3>Pager Decoding</h3>
|
||||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="signals">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>433MHz Sensors</h3>
|
<div class="feature-icon">✈️</div>
|
||||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="signals">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></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" data-category="signals">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
|
|
||||||
<h3>Listening Post</h3>
|
|
||||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="signals">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M8 12h1"/><path d="M11 12h2"/><path d="M15 12h1"/><path d="M18 12h2"/><circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><path d="M4 8h16"/><path d="M4 16h16"/></svg></div>
|
|
||||||
<h3>CW/Morse Decoder</h3>
|
|
||||||
<p>Morse code decoding with custom Goertzel tone detection for CW and OOK/AM envelope detection for ISM band signals.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="intel">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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></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" data-category="intel">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
|
|
||||||
<h3>Spy Stations</h3>
|
|
||||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="tracking">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></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" data-category="signals">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
|
||||||
<h3>Utility Meters</h3>
|
|
||||||
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="tracking">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
|
||||||
<h3>Aircraft Tracking</h3>
|
<h3>Aircraft Tracking</h3>
|
||||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="tracking">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>ACARS</h3>
|
<div class="feature-icon">📡</div>
|
||||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
<h3>433MHz Sensors</h3>
|
||||||
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="tracking">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>VDL2</h3>
|
<div class="feature-icon">📻</div>
|
||||||
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
<h3>Listening Post</h3>
|
||||||
|
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="tracking">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>Vessel Tracking</h3>
|
<div class="feature-icon">🛰️</div>
|
||||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="space">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></div>
|
|
||||||
<h3>Satellite Tracking</h3>
|
<h3>Satellite Tracking</h3>
|
||||||
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="space">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>Weather Satellites</h3>
|
<div class="feature-icon">📶</div>
|
||||||
<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" data-category="space">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></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" data-category="space">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></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" data-category="space">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/><path d="M3 9h18"/><path d="M6 3v18"/><path d="M18 3v18"/><path d="M9 6h6"/></svg></div>
|
|
||||||
<h3>WeFax</h3>
|
|
||||||
<p>HF weather fax decoder with broadcast timeline, auto-scheduler, and image gallery for marine weather charts.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="tracking">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></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" data-category="tracking">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><path d="M12 22v-6"/><circle cx="12" cy="12" r="4"/><path d="M8 12H2"/><path d="M22 12h-6"/><path d="M12 8a20 20 0 0 1 0 8"/><path d="M7 4l2 3"/><path d="M17 20l-2-3"/></svg></div>
|
|
||||||
<h3>Radiosonde</h3>
|
|
||||||
<p>Weather balloon tracking on 400-406 MHz via radiosonde_auto_rx. Real-time telemetry, trajectory map, and station distance.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="space">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
|
||||||
<h3>Space Weather</h3>
|
|
||||||
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="wireless">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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"/></svg></div>
|
|
||||||
<h3>WiFi Scanning</h3>
|
<h3>WiFi Scanning</h3>
|
||||||
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="wireless">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔵</div>
|
||||||
<h3>Bluetooth Scanning</h3>
|
<h3>Bluetooth Scanning</h3>
|
||||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="wireless">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>BT Locate</h3>
|
<div class="feature-icon">🛡️</div>
|
||||||
<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" data-category="wireless">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></div>
|
|
||||||
<h3>WiFi Locate</h3>
|
|
||||||
<p>Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card" data-category="intel">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
|
||||||
<h3>TSCM</h3>
|
<h3>TSCM</h3>
|
||||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="wireless">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>Meshtastic</h3>
|
<div class="feature-icon">⚡</div>
|
||||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
<h3>Meter Reading</h3>
|
||||||
|
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="platform">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></div>
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🚢</div>
|
||||||
|
<h3>Vessel Tracking</h3>
|
||||||
|
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔢</div>
|
||||||
|
<h3>Spy Stations</h3>
|
||||||
|
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌐</div>
|
||||||
<h3>Remote Agents</h3>
|
<h3>Remote Agents</h3>
|
||||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="platform">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></div>
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📴</div>
|
||||||
<h3>Offline Mode</h3>
|
<h3>Offline Mode</h3>
|
||||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="platform">
|
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
|
<div class="feature-card">
|
||||||
<h3>System Health</h3>
|
<div class="feature-icon">📡</div>
|
||||||
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
|
<h3>Meshtastic</h3>
|
||||||
</div>
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
</div>
|
|
||||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="carousel-indicators" id="carousel-indicators"></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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -272,50 +194,6 @@
|
|||||||
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||||
<span class="screenshot-label">AIS Vessel Tracking</span>
|
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
|
||||||
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/spy-stations.png" alt="Spy Stations Database">
|
|
||||||
<span class="screenshot-label">Spy Stations</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/gps.png" alt="GPS Receiver">
|
|
||||||
<span class="screenshot-label">GPS Receiver</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
|
||||||
<span class="screenshot-label">WebSDR</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/aprs.png" alt="APRS Tracker">
|
|
||||||
<span class="screenshot-label">APRS Tracker</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
|
||||||
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
|
||||||
<span class="screenshot-label">Weather Satellite</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
|
|
||||||
<span class="screenshot-label">Space Weather</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
|
|
||||||
<span class="screenshot-label">Space Weather — Solar & Aurora</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
|
||||||
<span class="screenshot-label">Satellite Tracker</span>
|
|
||||||
</div>
|
|
||||||
<div class="screenshot-item">
|
|
||||||
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
|
|
||||||
<span class="screenshot-label">ISS SSTV</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -335,10 +213,10 @@
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh # Interactive wizard with install profiles
|
./setup.sh
|
||||||
sudo ./start.sh</code></pre>
|
sudo -E venv/bin/python intercept.py</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="install-note">Menu-driven setup: choose Core, Maritime, Weather, Security, or Full SIGINT profiles. Headless mode: <code>./setup.sh --non-interactive</code></p>
|
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="install-card">
|
<div class="install-card">
|
||||||
@@ -346,7 +224,7 @@ sudo ./start.sh</code></pre>
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker compose --profile basic up -d --build</code></pre>
|
docker compose up -d</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +233,6 @@ docker compose --profile basic up -d --build</code></pre>
|
|||||||
<div class="post-install">
|
<div class="post-install">
|
||||||
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
||||||
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
||||||
<p>Run <code>./setup.sh --health-check</code> to verify your installation, or use menu option 2 for a full system health check.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -401,54 +278,22 @@ docker compose --profile basic up -d --build</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="support">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Support & Contact</h2>
|
|
||||||
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
|
|
||||||
|
|
||||||
<div class="support-grid">
|
|
||||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
|
|
||||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
|
|
||||||
<h3>Buy Me a Coffee</h3>
|
|
||||||
<p>Support development with a one-time donation</p>
|
|
||||||
</a>
|
|
||||||
<a href="#" id="email-card" class="support-card" onclick="return false;">
|
|
||||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
|
|
||||||
<h3>Email</h3>
|
|
||||||
<p id="email-text">Click to reveal</p>
|
|
||||||
</a>
|
|
||||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
|
|
||||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
|
||||||
<h3>Discord</h3>
|
|
||||||
<p>Join the community for help and discussion</p>
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
|
|
||||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
|
||||||
<h3>Report an Issue</h3>
|
|
||||||
<p>Bug reports and feature requests on GitHub</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<span class="footer-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</span>
|
<span class="footer-logo">iNTERCEPT</span>
|
||||||
<p>Signal Intelligence Platform</p>
|
<p>Signal Intelligence Platform</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||||
<a href="#" id="footer-email">Email</a>
|
|
||||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</a>
|
|
||||||
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||||
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
||||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,334 +336,6 @@ docker compose --profile basic up -d --build</code></pre>
|
|||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') closeLightbox();
|
if (e.key === 'Escape') closeLightbox();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Carousel functionality
|
|
||||||
(function() {
|
|
||||||
const track = document.querySelector('.carousel-track');
|
|
||||||
const cards = Array.from(track.querySelectorAll('.feature-card'));
|
|
||||||
const leftArrow = document.querySelector('.carousel-arrow-left');
|
|
||||||
const rightArrow = document.querySelector('.carousel-arrow-right');
|
|
||||||
const filterBtns = document.querySelectorAll('.filter-btn');
|
|
||||||
const indicatorContainer = document.getElementById('carousel-indicators');
|
|
||||||
|
|
||||||
const SCROLL_AMOUNT = 300;
|
|
||||||
|
|
||||||
function updateArrows() {
|
|
||||||
leftArrow.disabled = track.scrollLeft <= 0;
|
|
||||||
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildIndicators() {
|
|
||||||
const visible = cards.filter(c => !c.classList.contains('hidden'));
|
|
||||||
const totalWidth = visible.length * 300;
|
|
||||||
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
|
|
||||||
indicatorContainer.innerHTML = '';
|
|
||||||
for (let i = 0; i < pages; i++) {
|
|
||||||
const dot = document.createElement('button');
|
|
||||||
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
|
||||||
dot.addEventListener('click', () => {
|
|
||||||
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
indicatorContainer.appendChild(dot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateIndicators() {
|
|
||||||
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
|
|
||||||
if (!dots.length) return;
|
|
||||||
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
|
|
||||||
const idx = Math.round(ratio * (dots.length - 1));
|
|
||||||
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
|
|
||||||
}
|
|
||||||
|
|
||||||
leftArrow.addEventListener('click', () => {
|
|
||||||
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
rightArrow.addEventListener('click', () => {
|
|
||||||
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
track.addEventListener('scroll', () => {
|
|
||||||
updateArrows();
|
|
||||||
updateIndicators();
|
|
||||||
});
|
|
||||||
|
|
||||||
filterBtns.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
filterBtns.forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
const filter = btn.dataset.filter;
|
|
||||||
|
|
||||||
cards.forEach(card => {
|
|
||||||
if (filter === 'all' || card.dataset.category === filter) {
|
|
||||||
card.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
card.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
track.scrollTo({ left: 0 });
|
|
||||||
buildIndicators();
|
|
||||||
updateArrows();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
buildIndicators();
|
|
||||||
updateArrows();
|
|
||||||
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Obfuscated email - assembled at runtime to defeat scrapers
|
|
||||||
(function() {
|
|
||||||
const p = ['smittix', 'outlook', 'com'];
|
|
||||||
const addr = p[0] + '@' + p[1] + '.' + p[2];
|
|
||||||
const card = document.getElementById('email-card');
|
|
||||||
const text = document.getElementById('email-text');
|
|
||||||
const footerLink = document.getElementById('footer-email');
|
|
||||||
let revealed = false;
|
|
||||||
|
|
||||||
card.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!revealed) {
|
|
||||||
text.textContent = addr;
|
|
||||||
revealed = true;
|
|
||||||
} else {
|
|
||||||
window.location.href = 'mail' + 'to:' + addr;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
footerLink.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.location.href = 'mail' + 'to:' + addr;
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Animated satellite & signal background
|
|
||||||
(function() {
|
|
||||||
const canvas = document.getElementById('bg-canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
let w, h, dpr;
|
|
||||||
let orbits = [];
|
|
||||||
let pulses = [];
|
|
||||||
let particles = [];
|
|
||||||
let mouse = { x: -1000, y: -1000 };
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
w = window.innerWidth;
|
|
||||||
h = document.documentElement.scrollHeight;
|
|
||||||
canvas.width = w * dpr;
|
|
||||||
canvas.height = h * dpr;
|
|
||||||
canvas.style.width = w + 'px';
|
|
||||||
canvas.style.height = h + 'px';
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orbital paths with satellites
|
|
||||||
function createOrbits() {
|
|
||||||
orbits = [];
|
|
||||||
const count = Math.max(4, Math.floor(w / 300));
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const cx = Math.random() * w;
|
|
||||||
const cy = Math.random() * h;
|
|
||||||
const rx = 120 + Math.random() * 280;
|
|
||||||
const ry = 40 + Math.random() * 100;
|
|
||||||
const tilt = (Math.random() - 0.5) * 1.2;
|
|
||||||
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
|
|
||||||
const sats = [];
|
|
||||||
const satCount = 1 + Math.floor(Math.random() * 2);
|
|
||||||
for (let j = 0; j < satCount; j++) {
|
|
||||||
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
|
|
||||||
}
|
|
||||||
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floating signal particles (tiny dots drifting upward)
|
|
||||||
function createParticles() {
|
|
||||||
particles = [];
|
|
||||||
const count = Math.max(30, Math.floor((w * h) / 25000));
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
particles.push({
|
|
||||||
x: Math.random() * w,
|
|
||||||
y: Math.random() * h,
|
|
||||||
vy: -(0.08 + Math.random() * 0.15),
|
|
||||||
vx: (Math.random() - 0.5) * 0.1,
|
|
||||||
size: 0.5 + Math.random() * 1.2,
|
|
||||||
alpha: 0.1 + Math.random() * 0.25,
|
|
||||||
flicker: Math.random() * Math.PI * 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnPulse(x, y) {
|
|
||||||
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawOrbitPath(orbit) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(orbit.cx, orbit.cy);
|
|
||||||
ctx.rotate(orbit.tilt);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
|
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawSatellite(orbit, sat, dt) {
|
|
||||||
sat.angle += orbit.speed * dt;
|
|
||||||
const cos = Math.cos(orbit.tilt);
|
|
||||||
const sin = Math.sin(orbit.tilt);
|
|
||||||
const ex = orbit.rx * Math.cos(sat.angle);
|
|
||||||
const ey = orbit.ry * Math.sin(sat.angle);
|
|
||||||
const sx = orbit.cx + ex * cos - ey * sin;
|
|
||||||
const sy = orbit.cy + ex * sin + ey * cos;
|
|
||||||
|
|
||||||
// Satellite dot
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Faint glow
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
|
|
||||||
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
|
|
||||||
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
|
|
||||||
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Periodic signal pulse
|
|
||||||
sat.pulseTimer += dt;
|
|
||||||
if (sat.pulseTimer > 3000 + Math.random() * 500) {
|
|
||||||
sat.pulseTimer = 0;
|
|
||||||
spawnPulse(sx, sy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPulses(dt) {
|
|
||||||
for (let i = pulses.length - 1; i >= 0; i--) {
|
|
||||||
const p = pulses[i];
|
|
||||||
p.r += dt * 0.025;
|
|
||||||
p.alpha = 0.35 * (1 - p.r / p.maxR);
|
|
||||||
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
|
||||||
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Second ring
|
|
||||||
if (p.r > 12) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
|
|
||||||
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawParticles(dt, time) {
|
|
||||||
for (const p of particles) {
|
|
||||||
p.y += p.vy * dt * 0.06;
|
|
||||||
p.x += p.vx * dt * 0.06;
|
|
||||||
p.flicker += dt * 0.002;
|
|
||||||
|
|
||||||
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
|
|
||||||
if (p.x < -10) p.x = w + 10;
|
|
||||||
if (p.x > w + 10) p.x = -10;
|
|
||||||
|
|
||||||
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
|
|
||||||
|
|
||||||
// Mouse interaction - subtle brighten
|
|
||||||
const dx = p.x - mouse.x;
|
|
||||||
const dy = p.y - mouse.y;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Faint grid lines (signal grid)
|
|
||||||
function drawGrid(time) {
|
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
const spacing = 120;
|
|
||||||
const offset = (time * 0.005) % spacing;
|
|
||||||
|
|
||||||
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, h);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(w, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let last = 0;
|
|
||||||
function animate(now) {
|
|
||||||
const dt = last ? Math.min(now - last, 50) : 16;
|
|
||||||
last = now;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
drawGrid(now);
|
|
||||||
|
|
||||||
for (const orbit of orbits) {
|
|
||||||
drawOrbitPath(orbit);
|
|
||||||
for (const sat of orbit.sats) {
|
|
||||||
drawSatellite(orbit, sat, dt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPulses(dt);
|
|
||||||
drawParticles(dt, now);
|
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track mouse for particle interaction
|
|
||||||
document.addEventListener('mousemove', (e) => {
|
|
||||||
mouse.x = e.clientX;
|
|
||||||
mouse.y = e.clientY + window.scrollY;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resize handling
|
|
||||||
let resizeTimer;
|
|
||||||
function handleResize() {
|
|
||||||
clearTimeout(resizeTimer);
|
|
||||||
resizeTimer = setTimeout(() => {
|
|
||||||
resize();
|
|
||||||
createOrbits();
|
|
||||||
createParticles();
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep canvas height synced with document
|
|
||||||
const ro = new ResizeObserver(() => { handleResize(); });
|
|
||||||
ro.observe(document.documentElement);
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
resize();
|
|
||||||
createOrbits();
|
|
||||||
createParticles();
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,22 +17,6 @@
|
|||||||
--gradient-end: #0088ff;
|
--gradient-end: #0088ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated background canvas */
|
|
||||||
#bg-canvas {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body > *:not(#bg-canvas) {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -86,21 +70,6 @@ body {
|
|||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Branded "i" — inline SVG glyph matching the app logo */
|
|
||||||
.brand-i {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.55em;
|
|
||||||
height: 0.9em;
|
|
||||||
vertical-align: baseline;
|
|
||||||
position: relative;
|
|
||||||
top: 0.05em;
|
|
||||||
}
|
|
||||||
.brand-i svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -276,74 +245,18 @@ section h2 {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Category filter tabs */
|
.features-grid {
|
||||||
.carousel-filters {
|
display: grid;
|
||||||
display: flex;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
justify-content: center;
|
gap: 24px;
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.25s;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--bg-primary);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Carousel */
|
|
||||||
.carousel-wrapper {
|
|
||||||
position: relative;
|
|
||||||
padding: 0 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-track {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
overflow-x: auto;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding: 8px 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-track::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
flex: 0 0 280px;
|
|
||||||
scroll-snap-align: start;
|
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover {
|
.feature-card:hover {
|
||||||
@@ -353,15 +266,8 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
width: 36px;
|
font-size: 2rem;
|
||||||
height: 36px;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-icon svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card h3 {
|
.feature-card h3 {
|
||||||
@@ -377,81 +283,6 @@ section h2 {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Carousel arrows */
|
|
||||||
.carousel-arrow {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.25s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow:disabled:hover {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow-left {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow-right {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Carousel indicators */
|
|
||||||
.carousel-indicators {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--border);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.25s;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-dot.active {
|
|
||||||
background: var(--accent);
|
|
||||||
width: 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-dot:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screenshots */
|
/* Screenshots */
|
||||||
.screenshot-gallery {
|
.screenshot-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -719,72 +550,6 @@ section h2 {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Support & Contact */
|
|
||||||
.support {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 32px 24px;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.3s;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-card:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
border-color: var(--accent);
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-card.support-coffee {
|
|
||||||
border-color: rgba(255, 193, 59, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-card.support-coffee:hover {
|
|
||||||
border-color: #ffc13b;
|
|
||||||
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-card.support-coffee .support-icon {
|
|
||||||
color: #ffc13b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-icon svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-card h3 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-card p {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -876,19 +641,11 @@ section h2 {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-wrapper {
|
.features-grid {
|
||||||
padding: 0 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card {
|
|
||||||
flex: 0 0 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screenshot-gallery {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-grid {
|
.screenshot-gallery {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,32 +669,11 @@ section h2 {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-wrapper {
|
.features-grid {
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card {
|
|
||||||
flex: 0 0 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-filters {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 6px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screenshot-gallery {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-grid {
|
.screenshot-gallery {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
DMSP 5D-3 F16 (USA 172)
|
|
||||||
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
|
|
||||||
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
|
|
||||||
METEOSAT-9 (MSG-2)
|
|
||||||
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
|
|
||||||
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
|
|
||||||
DMSP 5D-3 F17 (USA 191)
|
|
||||||
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
|
|
||||||
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
|
|
||||||
FENGYUN 3A
|
|
||||||
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
|
|
||||||
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
|
|
||||||
GOES 14
|
|
||||||
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
|
|
||||||
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
|
|
||||||
DMSP 5D-3 F18 (USA 210)
|
|
||||||
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
|
|
||||||
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
|
|
||||||
EWS-G2 (GOES 15)
|
|
||||||
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
|
|
||||||
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
|
|
||||||
COMS 1
|
|
||||||
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
|
|
||||||
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
|
|
||||||
FENGYUN 3B
|
|
||||||
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
|
|
||||||
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
|
|
||||||
SUOMI NPP
|
|
||||||
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
|
|
||||||
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
|
|
||||||
METEOSAT-10 (MSG-3)
|
|
||||||
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
|
|
||||||
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
|
|
||||||
METOP-B
|
|
||||||
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
|
|
||||||
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
|
|
||||||
INSAT-3D
|
|
||||||
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
|
|
||||||
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
|
|
||||||
FENGYUN 3C
|
|
||||||
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
|
|
||||||
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
|
|
||||||
METEOR-M 2
|
|
||||||
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
|
|
||||||
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
|
|
||||||
HIMAWARI-8
|
|
||||||
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
|
|
||||||
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
|
|
||||||
FENGYUN 2G
|
|
||||||
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
|
|
||||||
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
|
|
||||||
METEOSAT-11 (MSG-4)
|
|
||||||
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
|
|
||||||
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
|
|
||||||
ELEKTRO-L 2
|
|
||||||
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
|
|
||||||
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
|
|
||||||
INSAT-3DR
|
|
||||||
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
|
|
||||||
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
|
|
||||||
HIMAWARI-9
|
|
||||||
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
|
|
||||||
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
|
|
||||||
GOES 16
|
|
||||||
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
|
|
||||||
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
|
|
||||||
FENGYUN 4A
|
|
||||||
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
|
|
||||||
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
|
|
||||||
CYGFM05
|
|
||||||
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
|
|
||||||
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
|
|
||||||
CYGFM04
|
|
||||||
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
|
|
||||||
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
|
|
||||||
CYGFM02
|
|
||||||
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
|
|
||||||
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
|
|
||||||
CYGFM01
|
|
||||||
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
|
|
||||||
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
|
|
||||||
CYGFM08
|
|
||||||
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
|
|
||||||
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
|
|
||||||
CYGFM07
|
|
||||||
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
|
|
||||||
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
|
|
||||||
CYGFM03
|
|
||||||
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
|
|
||||||
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
|
|
||||||
FENGYUN 3D
|
|
||||||
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
|
|
||||||
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
|
|
||||||
NOAA 20 (JPSS-1)
|
|
||||||
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
|
|
||||||
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
|
|
||||||
GOES 17
|
|
||||||
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
|
|
||||||
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
|
|
||||||
FENGYUN 2H
|
|
||||||
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
|
|
||||||
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
|
|
||||||
METOP-C
|
|
||||||
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
|
|
||||||
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
|
|
||||||
GEO-KOMPSAT-2A
|
|
||||||
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
|
|
||||||
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
|
|
||||||
METEOR-M2 2
|
|
||||||
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
|
|
||||||
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
|
|
||||||
ARKTIKA-M 1
|
|
||||||
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
|
|
||||||
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
|
|
||||||
FENGYUN 3E
|
|
||||||
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
|
|
||||||
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
|
|
||||||
GOES 18
|
|
||||||
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
|
|
||||||
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
|
|
||||||
NOAA 21 (JPSS-2)
|
|
||||||
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
|
|
||||||
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
|
|
||||||
METEOSAT-12 (MTG-I1)
|
|
||||||
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
|
|
||||||
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
|
|
||||||
TIANMU-1 03
|
|
||||||
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
|
|
||||||
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
|
|
||||||
TIANMU-1 04
|
|
||||||
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
|
|
||||||
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
|
|
||||||
TIANMU-1 05
|
|
||||||
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
|
|
||||||
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
|
|
||||||
TIANMU-1 06
|
|
||||||
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
|
|
||||||
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
|
|
||||||
FENGYUN 3G
|
|
||||||
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
|
|
||||||
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
|
|
||||||
METEOR-M2 3
|
|
||||||
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
|
|
||||||
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
|
|
||||||
TIANMU-1 07
|
|
||||||
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
|
|
||||||
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
|
|
||||||
TIANMU-1 08
|
|
||||||
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
|
|
||||||
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
|
|
||||||
TIANMU-1 09
|
|
||||||
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
|
|
||||||
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
|
|
||||||
TIANMU-1 10
|
|
||||||
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
|
|
||||||
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
|
|
||||||
FENGYUN 3F
|
|
||||||
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
|
|
||||||
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
|
|
||||||
ARKTIKA-M 2
|
|
||||||
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
|
|
||||||
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
|
|
||||||
TIANMU-1 11
|
|
||||||
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
|
|
||||||
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
|
|
||||||
TIANMU-1 12
|
|
||||||
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
|
|
||||||
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
|
|
||||||
TIANMU-1 13
|
|
||||||
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
|
|
||||||
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
|
|
||||||
TIANMU-1 14
|
|
||||||
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
|
|
||||||
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
|
|
||||||
TIANMU-1 19
|
|
||||||
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
|
|
||||||
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
|
|
||||||
TIANMU-1 20
|
|
||||||
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
|
|
||||||
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
|
|
||||||
TIANMU-1 21
|
|
||||||
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
|
|
||||||
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
|
|
||||||
TIANMU-1 22
|
|
||||||
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
|
|
||||||
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
|
|
||||||
TIANMU-1 15
|
|
||||||
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
|
|
||||||
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
|
|
||||||
TIANMU-1 16
|
|
||||||
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
|
|
||||||
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
|
|
||||||
TIANMU-1 17
|
|
||||||
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
|
|
||||||
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
|
|
||||||
TIANMU-1 18
|
|
||||||
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
|
|
||||||
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
|
|
||||||
INSAT-3DS
|
|
||||||
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
|
|
||||||
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
|
|
||||||
METEOR-M2 4
|
|
||||||
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
|
|
||||||
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
|
|
||||||
GOES 19
|
|
||||||
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
|
|
||||||
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
|
|
||||||
FENGYUN 3H
|
|
||||||
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
|
|
||||||
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Gunicorn configuration for INTERCEPT."""
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
warnings.filterwarnings(
|
|
||||||
'ignore',
|
|
||||||
message='Patching more than once',
|
|
||||||
category=DeprecationWarning,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def post_fork(server, worker):
|
|
||||||
"""Apply gevent monkey-patching immediately after fork.
|
|
||||||
|
|
||||||
Gunicorn's built-in gevent worker is supposed to handle this, but on
|
|
||||||
some platforms (notably Raspberry Pi / ARM) the worker deadlocks during
|
|
||||||
its own init_process() before it gets to patch. Doing it here — right
|
|
||||||
after fork, before any worker initialisation — avoids the race.
|
|
||||||
|
|
||||||
Gunicorn's gevent worker will call patch_all() again in init_process();
|
|
||||||
the duplicate call is harmless (gevent unions the flags) and the
|
|
||||||
MonkeyPatchWarning is suppressed above.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from gevent import monkey
|
|
||||||
monkey.patch_all()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Silence the spurious AssertionError in gevent's fork hooks that fires
|
|
||||||
# when subprocesses fork after a double monkey-patch.
|
|
||||||
try:
|
|
||||||
from gevent.threading import _ForkHooks
|
|
||||||
_orig = _ForkHooks.after_fork_in_child
|
|
||||||
|
|
||||||
def _safe_after_fork(self):
|
|
||||||
with contextlib.suppress(AssertionError):
|
|
||||||
_orig(self)
|
|
||||||
|
|
||||||
_ForkHooks.after_fork_in_child = _safe_after_fork
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def post_worker_init(worker):
|
|
||||||
"""Suppress noisy SystemExit tracebacks during gevent worker shutdown.
|
|
||||||
|
|
||||||
When gunicorn receives SIGINT, the gevent worker's handle_quit()
|
|
||||||
calls sys.exit(0) inside a greenlet. Gevent treats SystemExit as
|
|
||||||
an error by default and prints a traceback. Adding it to NOT_ERROR
|
|
||||||
silences this harmless noise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import ssl
|
|
||||||
|
|
||||||
from gevent import get_hub
|
|
||||||
hub = get_hub()
|
|
||||||
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
|
|
||||||
for exc in suppress:
|
|
||||||
if exc not in hub.NOT_ERROR:
|
|
||||||
hub.NOT_ERROR = hub.NOT_ERROR + (exc,)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@@ -16,6 +16,14 @@ Requires RTL-SDR hardware for RF modes.
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Check Python version early, before imports that use 3.9+ syntax
|
# Check Python version early, before imports that use 3.9+ syntax
|
||||||
|
if sys.version_info < (3, 9):
|
||||||
|
print(f"Error: Python 3.9 or higher is required.")
|
||||||
|
print(f"You are running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
||||||
|
print("\nTo fix this:")
|
||||||
|
print(" - On Ubuntu/Debian: sudo apt install python3.9 (or newer)")
|
||||||
|
print(" - On macOS: brew install python@3.11")
|
||||||
|
print(" - Or use pyenv to install a newer version")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Handle --version early before other imports
|
# Handle --version early before other imports
|
||||||
if '--version' in sys.argv or '-V' in sys.argv:
|
if '--version' in sys.argv or '-V' in sys.argv:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
import contextlib
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -27,24 +26,25 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
from socketserver import ThreadingMixIn
|
from socketserver import ThreadingMixIn
|
||||||
from urllib.parse import parse_qs, urlparse
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
# Add parent directory to path for imports
|
# Add parent directory to path for imports
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
# Import dependency checking from Intercept utils
|
# Import dependency checking from Intercept utils
|
||||||
try:
|
try:
|
||||||
from utils.dependencies import TOOL_DEPENDENCIES, check_all_dependencies, check_tool
|
from utils.dependencies import check_all_dependencies, check_tool, TOOL_DEPENDENCIES
|
||||||
HAS_DEPENDENCIES_MODULE = True
|
HAS_DEPENDENCIES_MODULE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_DEPENDENCIES_MODULE = False
|
HAS_DEPENDENCIES_MODULE = False
|
||||||
|
|
||||||
# Import TSCM modules for consistent analysis (same as local mode)
|
# Import TSCM modules for consistent analysis (same as local mode)
|
||||||
try:
|
try:
|
||||||
from utils.tscm.correlation import CorrelationEngine
|
|
||||||
from utils.tscm.detector import ThreatDetector
|
from utils.tscm.detector import ThreatDetector
|
||||||
|
from utils.tscm.correlation import CorrelationEngine
|
||||||
HAS_TSCM_MODULES = True
|
HAS_TSCM_MODULES = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_TSCM_MODULES = False
|
HAS_TSCM_MODULES = False
|
||||||
@@ -53,7 +53,7 @@ except ImportError:
|
|||||||
|
|
||||||
# Import database functions for baseline support (same as local mode)
|
# Import database functions for baseline support (same as local mode)
|
||||||
try:
|
try:
|
||||||
from utils.database import get_active_tscm_baseline, get_tscm_baseline
|
from utils.database import get_tscm_baseline, get_active_tscm_baseline
|
||||||
HAS_BASELINE_DB = True
|
HAS_BASELINE_DB = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_BASELINE_DB = False
|
HAS_BASELINE_DB = False
|
||||||
@@ -143,7 +143,7 @@ class AgentConfig:
|
|||||||
|
|
||||||
# Modes section
|
# Modes section
|
||||||
if parser.has_section('modes'):
|
if parser.has_section('modes'):
|
||||||
for mode in self.modes_enabled:
|
for mode in self.modes_enabled.keys():
|
||||||
if parser.has_option('modes', mode):
|
if parser.has_option('modes', mode):
|
||||||
self.modes_enabled[mode] = parser.getboolean('modes', mode)
|
self.modes_enabled[mode] = parser.getboolean('modes', mode)
|
||||||
|
|
||||||
@@ -310,8 +310,10 @@ class ControllerPushClient(threading.Thread):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
item['attempts'] += 1
|
item['attempts'] += 1
|
||||||
if item['attempts'] < 3 and not self.stop_event.is_set():
|
if item['attempts'] < 3 and not self.stop_event.is_set():
|
||||||
with contextlib.suppress(queue.Full):
|
try:
|
||||||
self.queue.put_nowait(item)
|
self.queue.put_nowait(item)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
|
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
|
||||||
finally:
|
finally:
|
||||||
@@ -793,7 +795,9 @@ class ModeManager:
|
|||||||
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
|
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
|
||||||
elif mode == 'aprs':
|
elif mode == 'aprs':
|
||||||
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
|
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
|
||||||
elif mode == 'pager' or mode == 'acars':
|
elif mode == 'pager':
|
||||||
|
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
||||||
|
elif mode == 'acars':
|
||||||
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
||||||
elif mode == 'rtlamr':
|
elif mode == 'rtlamr':
|
||||||
info['reading_count'] = len(self.data_snapshots.get(mode, []))
|
info['reading_count'] = len(self.data_snapshots.get(mode, []))
|
||||||
@@ -839,7 +843,6 @@ class ModeManager:
|
|||||||
'anomalies': getattr(self, 'tscm_anomalies', []),
|
'anomalies': getattr(self, 'tscm_anomalies', []),
|
||||||
'baseline': getattr(self, 'tscm_baseline', {}),
|
'baseline': getattr(self, 'tscm_baseline', {}),
|
||||||
'wifi_devices': list(self.wifi_networks.values()),
|
'wifi_devices': list(self.wifi_networks.values()),
|
||||||
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
|
||||||
'bt_devices': list(self.bluetooth_devices.values()),
|
'bt_devices': list(self.bluetooth_devices.values()),
|
||||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
||||||
}
|
}
|
||||||
@@ -1069,8 +1072,10 @@ class ModeManager:
|
|||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except (OSError, ProcessLookupError) as e:
|
except (OSError, ProcessLookupError) as e:
|
||||||
# Process already dead or inaccessible
|
# Process already dead or inaccessible
|
||||||
logger.debug(f"Process cleanup for {mode}: {e}")
|
logger.debug(f"Process cleanup for {mode}: {e}")
|
||||||
@@ -1111,7 +1116,6 @@ class ModeManager:
|
|||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
self.tscm_baseline = {}
|
self.tscm_baseline = {}
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
self.tscm_wifi_clients = {}
|
|
||||||
# Clear reported threat tracking sets
|
# Clear reported threat tracking sets
|
||||||
if hasattr(self, '_tscm_reported_wifi'):
|
if hasattr(self, '_tscm_reported_wifi'):
|
||||||
self._tscm_reported_wifi.clear()
|
self._tscm_reported_wifi.clear()
|
||||||
@@ -1291,8 +1295,10 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Sensor output reader error: {e}")
|
logger.error(f"Sensor output reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Sensor output reader stopped")
|
logger.info("Sensor output reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -1535,7 +1541,6 @@ class ModeManager:
|
|||||||
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
||||||
interface = params.get('interface')
|
interface = params.get('interface')
|
||||||
channel = params.get('channel')
|
channel = params.get('channel')
|
||||||
channels = params.get('channels')
|
|
||||||
band = params.get('band', 'abg')
|
band = params.get('band', 'abg')
|
||||||
scan_type = params.get('scan_type', 'deep')
|
scan_type = params.get('scan_type', 'deep')
|
||||||
|
|
||||||
@@ -1566,21 +1571,8 @@ class ModeManager:
|
|||||||
else:
|
else:
|
||||||
scan_band = 'all'
|
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
|
# Start deep scan
|
||||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
|
||||||
# Start thread to sync data to agent's dictionaries
|
# Start thread to sync data to agent's dictionaries
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._wifi_data_sync,
|
target=self._wifi_data_sync,
|
||||||
@@ -1601,7 +1593,7 @@ class ModeManager:
|
|||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback to direct airodump-ng
|
# Fallback to direct airodump-ng
|
||||||
return self._start_wifi_fallback(interface, channel, band, channels)
|
return self._start_wifi_fallback(interface, channel, band)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WiFi scanner error: {e}")
|
logger.error(f"WiFi scanner error: {e}")
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
@@ -1638,13 +1630,7 @@ class ModeManager:
|
|||||||
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
||||||
self._wifi_scanner_instance.stop_deep_scan()
|
self._wifi_scanner_instance.stop_deep_scan()
|
||||||
|
|
||||||
def _start_wifi_fallback(
|
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
|
||||||
self,
|
|
||||||
interface: str | None,
|
|
||||||
channel: int | None,
|
|
||||||
band: str,
|
|
||||||
channels: list[int] | str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
return {'status': 'error', 'message': 'WiFi interface required'}
|
||||||
@@ -1653,14 +1639,16 @@ class ModeManager:
|
|||||||
try:
|
try:
|
||||||
from utils.validation import validate_network_interface
|
from utils.validation import validate_network_interface
|
||||||
interface = validate_network_interface(interface)
|
interface = validate_network_interface(interface)
|
||||||
except (ImportError, ValueError):
|
except (ImportError, ValueError) as e:
|
||||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||||
return {'status': 'error', 'message': f'Interface {interface} not found'}
|
return {'status': 'error', 'message': f'Interface {interface} not found'}
|
||||||
|
|
||||||
csv_path = '/tmp/intercept_agent_wifi'
|
csv_path = '/tmp/intercept_agent_wifi'
|
||||||
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
|
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.remove(f)
|
os.remove(f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
airodump_path = self._get_tool_path('airodump-ng')
|
airodump_path = self._get_tool_path('airodump-ng')
|
||||||
if not airodump_path:
|
if not airodump_path:
|
||||||
@@ -1670,22 +1658,7 @@ class ModeManager:
|
|||||||
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
||||||
if gps_manager.is_running:
|
if gps_manager.is_running:
|
||||||
cmd.append('--gpsd')
|
cmd.append('--gpsd')
|
||||||
channel_list = None
|
if channel:
|
||||||
if channels:
|
|
||||||
if isinstance(channels, str):
|
|
||||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
|
||||||
elif isinstance(channels, (list, tuple, set)):
|
|
||||||
channel_list = list(channels)
|
|
||||||
else:
|
|
||||||
channel_list = [channels]
|
|
||||||
try:
|
|
||||||
channel_list = [int(c) for c in channel_list]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {'status': 'error', 'message': 'Invalid channels'}
|
|
||||||
|
|
||||||
if channel_list:
|
|
||||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
|
||||||
elif channel:
|
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
cmd.append(interface)
|
cmd.append(interface)
|
||||||
|
|
||||||
@@ -1921,7 +1894,7 @@ class ModeManager:
|
|||||||
logger.warning("Intercept WiFi parser not available, using fallback")
|
logger.warning("Intercept WiFi parser not available, using fallback")
|
||||||
# Fallback: simple parsing if running standalone
|
# Fallback: simple parsing if running standalone
|
||||||
try:
|
try:
|
||||||
with open(csv_path, errors='replace') as f:
|
with open(csv_path, 'r', errors='replace') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
for section in content.split('\n\n'):
|
for section in content.split('\n\n'):
|
||||||
lines = section.strip().split('\n')
|
lines = section.strip().split('\n')
|
||||||
@@ -2012,7 +1985,7 @@ class ModeManager:
|
|||||||
'agent_gps': gps_manager.position
|
'agent_gps': gps_manager.position
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner.add_device_callback(on_device_updated)
|
scanner.set_on_device_updated(on_device_updated)
|
||||||
|
|
||||||
# Start scanning
|
# Start scanning
|
||||||
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
||||||
@@ -2293,8 +2266,10 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Pager reader error: {e}")
|
logger.error(f"Pager reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'pager_rtl' in self.processes:
|
if 'pager_rtl' in self.processes:
|
||||||
try:
|
try:
|
||||||
rtl_proc = self.processes['pager_rtl']
|
rtl_proc = self.processes['pager_rtl']
|
||||||
@@ -2479,7 +2454,7 @@ class ModeManager:
|
|||||||
|
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
if retry_count >= 10:
|
if retry_count >= 10:
|
||||||
logger.error("Max AIS retries reached")
|
logger.error("Max AIS retries reached")
|
||||||
@@ -2689,8 +2664,10 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ACARS reader error: {e}")
|
logger.error(f"ACARS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("ACARS reader stopped")
|
logger.info("ACARS reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2832,8 +2809,10 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"APRS reader error: {e}")
|
logger.error(f"APRS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'aprs_rtl' in self.processes:
|
if 'aprs_rtl' in self.processes:
|
||||||
try:
|
try:
|
||||||
rtl_proc = self.processes['aprs_rtl']
|
rtl_proc = self.processes['aprs_rtl']
|
||||||
@@ -2846,17 +2825,6 @@ class ModeManager:
|
|||||||
|
|
||||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||||
"""Parse APRS packet from direwolf or multimon-ng."""
|
"""Parse APRS packet from direwolf or multimon-ng."""
|
||||||
if not line:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Normalize common decoder prefixes before parsing.
|
|
||||||
# multimon-ng: "AFSK1200: ..."
|
|
||||||
# direwolf: "[0.4] ...", "[0L] ..."
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith('AFSK1200:'):
|
|
||||||
line = line[9:].strip()
|
|
||||||
line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line)
|
|
||||||
|
|
||||||
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
||||||
if not match:
|
if not match:
|
||||||
return None
|
return None
|
||||||
@@ -3005,8 +2973,10 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"RTLAMR reader error: {e}")
|
logger.error(f"RTLAMR reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'rtlamr_tcp' in self.processes:
|
if 'rtlamr_tcp' in self.processes:
|
||||||
try:
|
try:
|
||||||
tcp_proc = self.processes['rtlamr_tcp']
|
tcp_proc = self.processes['rtlamr_tcp']
|
||||||
@@ -3124,8 +3094,10 @@ class ModeManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DSC reader error: {e}")
|
logger.error(f"DSC reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.wait(timeout=1)
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("DSC reader stopped")
|
logger.info("DSC reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -3141,10 +3113,7 @@ class ModeManager:
|
|||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
if not hasattr(self, 'tscm_rf_signals'):
|
if not hasattr(self, 'tscm_rf_signals'):
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
if not hasattr(self, 'tscm_wifi_clients'):
|
|
||||||
self.tscm_wifi_clients = {}
|
|
||||||
self.tscm_anomalies.clear()
|
self.tscm_anomalies.clear()
|
||||||
self.tscm_wifi_clients.clear()
|
|
||||||
|
|
||||||
# Get params for what to scan
|
# Get params for what to scan
|
||||||
scan_wifi = params.get('wifi', True)
|
scan_wifi = params.get('wifi', True)
|
||||||
@@ -3199,13 +3168,13 @@ class ModeManager:
|
|||||||
stop_event = self.stop_events.get(mode)
|
stop_event = self.stop_events.get(mode)
|
||||||
|
|
||||||
# Import existing Intercept TSCM functions
|
# Import existing Intercept TSCM functions
|
||||||
from routes.tscm import _scan_bluetooth_devices, _scan_rf_signals, _scan_wifi_clients, _scan_wifi_networks
|
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
|
||||||
logger.info("TSCM imports successful")
|
logger.info("TSCM imports successful")
|
||||||
|
|
||||||
sweep_ranges = None
|
sweep_ranges = None
|
||||||
if sweep_type:
|
if sweep_type:
|
||||||
try:
|
try:
|
||||||
from data.tscm_frequencies import SWEEP_PRESETS, get_sweep_preset
|
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
||||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||||
sweep_ranges = preset.get('ranges') if preset else None
|
sweep_ranges = preset.get('ranges') if preset else None
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3234,7 +3203,6 @@ class ModeManager:
|
|||||||
|
|
||||||
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
||||||
seen_wifi = {}
|
seen_wifi = {}
|
||||||
seen_wifi_clients = {}
|
|
||||||
seen_bt = {}
|
seen_bt = {}
|
||||||
|
|
||||||
last_rf_scan = 0
|
last_rf_scan = 0
|
||||||
@@ -3295,47 +3263,6 @@ class ModeManager:
|
|||||||
enriched['recommended_action'] = profile.recommended_action
|
enriched['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
self.wifi_networks[bssid] = enriched
|
self.wifi_networks[bssid] = enriched
|
||||||
|
|
||||||
# WiFi clients (monitor mode only)
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.debug(f"WiFi scan error: {e}")
|
logger.debug(f"WiFi scan error: {e}")
|
||||||
|
|
||||||
@@ -3392,8 +3319,7 @@ class ModeManager:
|
|||||||
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
|
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
|
||||||
try:
|
try:
|
||||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
||||||
def agent_stop_check():
|
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
||||||
return stop_event and stop_event.is_set()
|
|
||||||
rf_signals = _scan_rf_signals(
|
rf_signals = _scan_rf_signals(
|
||||||
sdr_device,
|
sdr_device,
|
||||||
stop_check=agent_stop_check,
|
stop_check=agent_stop_check,
|
||||||
@@ -3591,8 +3517,10 @@ class ModeManager:
|
|||||||
# Ensure test process is killed on any error
|
# Ensure test process is killed on any error
|
||||||
if test_proc and test_proc.poll() is None:
|
if test_proc and test_proc.poll() is None:
|
||||||
test_proc.kill()
|
test_proc.kill()
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
test_proc.wait(timeout=1)
|
test_proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
|
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
|
||||||
|
|
||||||
# Initialize state
|
# Initialize state
|
||||||
@@ -3626,9 +3554,9 @@ class ModeManager:
|
|||||||
step: float, modulation: str, squelch: int,
|
step: float, modulation: str, squelch: int,
|
||||||
device: str, gain: str, dwell_time: float = 1.0):
|
device: str, gain: str, dwell_time: float = 1.0):
|
||||||
"""Scan frequency range and report signal detections."""
|
"""Scan frequency range and report signal detections."""
|
||||||
import fcntl
|
|
||||||
import os
|
|
||||||
import select
|
import select
|
||||||
|
import os
|
||||||
|
import fcntl
|
||||||
|
|
||||||
mode = 'listening_post'
|
mode = 'listening_post'
|
||||||
stop_event = self.stop_events.get(mode)
|
stop_event = self.stop_events.get(mode)
|
||||||
@@ -3688,7 +3616,7 @@ class ModeManager:
|
|||||||
signal_detected = True
|
signal_detected = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except (OSError, BlockingIOError):
|
except (IOError, BlockingIOError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -4110,19 +4038,27 @@ def main():
|
|||||||
|
|
||||||
# Stop push services
|
# Stop push services
|
||||||
if data_push_loop:
|
if data_push_loop:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
data_push_loop.stop()
|
data_push_loop.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if push_client:
|
if push_client:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
push_client.stop()
|
push_client.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Stop GPS
|
# Stop GPS
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
gps_manager.stop()
|
gps_manager.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Shutdown HTTP server
|
# Shutdown HTTP server
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
httpd.shutdown()
|
httpd.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Run cleanup in background thread so signal handler returns quickly
|
# Run cleanup in background thread so signal handler returns quickly
|
||||||
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.26.8"
|
version = "2.14.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "MIT"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Intercept Contributors"}
|
{name = "Intercept Contributors"}
|
||||||
]
|
]
|
||||||
@@ -14,7 +14,7 @@ classifiers = [
|
|||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Framework :: Flask",
|
"Framework :: Flask",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Operating System :: MacOS",
|
"Operating System :: MacOS",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
@@ -93,32 +93,8 @@ ignore = [
|
|||||||
"B008", # do not perform function calls in argument defaults
|
"B008", # do not perform function calls in argument defaults
|
||||||
"B905", # zip without explicit strict
|
"B905", # zip without explicit strict
|
||||||
"SIM108", # use ternary operator instead of if-else
|
"SIM108", # use ternary operator instead of if-else
|
||||||
"SIM102", # collapsible if statements
|
|
||||||
"SIM105", # use contextlib.suppress (stylistic, not a bug)
|
|
||||||
"SIM115", # use context manager for open (not always applicable)
|
|
||||||
"SIM116", # use dict instead of if/elif chain (stylistic)
|
|
||||||
"SIM117", # combine nested with statements (stylistic)
|
|
||||||
"E402", # module-level import not at top (needed for conditional imports)
|
|
||||||
"E741", # ambiguous variable name
|
|
||||||
"E721", # type comparison (use isinstance)
|
|
||||||
"E722", # bare except
|
|
||||||
"B904", # raise from within except (stylistic)
|
|
||||||
"B007", # unused loop variable (use _ prefix)
|
|
||||||
"B023", # function definition doesn't bind loop variable
|
|
||||||
"F601", # membership test with duplicate items
|
|
||||||
"F821", # undefined name (too many false positives with conditional imports)
|
|
||||||
"UP035", # deprecated typing imports
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
"__init__.py" = ["F401"] # re-exports in __init__.py are intentional
|
|
||||||
"utils/bluetooth/capability_check.py" = ["F401"] # imports used for availability checking
|
|
||||||
"utils/bluetooth/fallback_scanner.py" = ["F401"] # imports used for availability checking
|
|
||||||
"utils/tscm/ble_scanner.py" = ["F401"] # imports used for availability checking
|
|
||||||
"utils/wifi/deauth_detector.py" = ["F401"] # imports used for availability checking
|
|
||||||
"routes/dsc.py" = ["F401"] # imports used for availability checking
|
|
||||||
"intercept_agent.py" = ["F401"] # conditional imports
|
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["app", "config", "routes", "utils", "data"]
|
known-first-party = ["app", "config", "routes", "utils", "data"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
flask>=3.0.0
|
flask>=3.0.0
|
||||||
flask-wtf>=1.2.0
|
|
||||||
flask-compress>=1.15
|
|
||||||
flask-limiter>=2.5.4
|
flask-limiter>=2.5.4
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
Werkzeug>=3.1.5
|
Werkzeug>=3.1.5
|
||||||
@@ -34,9 +32,6 @@ scapy>=2.4.5
|
|||||||
# QR code generation for Meshtastic channels (optional)
|
# QR code generation for Meshtastic channels (optional)
|
||||||
qrcode[pil]>=7.4
|
qrcode[pil]>=7.4
|
||||||
|
|
||||||
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
|
|
||||||
cryptography>=41.0.0
|
|
||||||
|
|
||||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||||
# pytest>=7.0.0
|
# pytest>=7.0.0
|
||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
@@ -46,10 +41,3 @@ cryptography>=41.0.0
|
|||||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||||
flask-sock
|
flask-sock
|
||||||
websocket-client>=1.6.0
|
websocket-client>=1.6.0
|
||||||
|
|
||||||
# System health monitoring (optional - graceful fallback if unavailable)
|
|
||||||
psutil>=5.9.0
|
|
||||||
|
|
||||||
# Production WSGI server (optional - falls back to Flask dev server)
|
|
||||||
gunicorn>=21.2.0
|
|
||||||
gevent>=23.9.0
|
|
||||||
|
|||||||
@@ -1,53 +1,34 @@
|
|||||||
# Routes package - registers all blueprints with the Flask app
|
# Routes package - registers all blueprints with the Flask app
|
||||||
|
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
|
|
||||||
try:
|
|
||||||
from app import csrf as _csrf
|
|
||||||
except ImportError:
|
|
||||||
_csrf = None
|
|
||||||
from .acars import acars_bp
|
|
||||||
from .adsb import adsb_bp
|
|
||||||
from .ais import ais_bp
|
|
||||||
from .alerts import alerts_bp
|
|
||||||
from .aprs import aprs_bp
|
|
||||||
from .bluetooth import bluetooth_bp
|
|
||||||
from .bluetooth_v2 import bluetooth_v2_bp
|
|
||||||
from .bt_locate import bt_locate_bp
|
|
||||||
from .controller import controller_bp
|
|
||||||
from .correlation import correlation_bp
|
|
||||||
from .dsc import dsc_bp
|
|
||||||
from .gps import gps_bp
|
|
||||||
from .listening_post import receiver_bp
|
|
||||||
from .meshtastic import meshtastic_bp
|
|
||||||
from .meteor_websocket import meteor_bp
|
|
||||||
from .morse import morse_bp
|
|
||||||
from .offline import offline_bp
|
|
||||||
from .ook import ook_bp
|
|
||||||
from .pager import pager_bp
|
from .pager import pager_bp
|
||||||
from .radiosonde import radiosonde_bp
|
|
||||||
from .recordings import recordings_bp
|
|
||||||
from .rtlamr import rtlamr_bp
|
|
||||||
from .satellite import satellite_bp
|
|
||||||
from .sensor import sensor_bp
|
from .sensor import sensor_bp
|
||||||
from .settings import settings_bp
|
from .rtlamr import rtlamr_bp
|
||||||
from .signalid import signalid_bp
|
|
||||||
from .space_weather import space_weather_bp
|
|
||||||
from .spy_stations import spy_stations_bp
|
|
||||||
from .sstv import sstv_bp
|
|
||||||
from .sstv_general import sstv_general_bp
|
|
||||||
from .subghz import subghz_bp
|
|
||||||
from .system import system_bp
|
|
||||||
from .tscm import init_tscm_state, tscm_bp
|
|
||||||
from .updater import updater_bp
|
|
||||||
from .vdl2 import vdl2_bp
|
|
||||||
from .weather_sat import weather_sat_bp
|
|
||||||
from .websdr import websdr_bp
|
|
||||||
from .wefax import wefax_bp
|
|
||||||
from .wifi import wifi_bp
|
from .wifi import wifi_bp
|
||||||
from .wifi_v2 import wifi_v2_bp
|
from .wifi_v2 import wifi_v2_bp
|
||||||
|
from .bluetooth import bluetooth_bp
|
||||||
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
|
from .adsb import adsb_bp
|
||||||
|
from .ais import ais_bp
|
||||||
|
from .dsc import dsc_bp
|
||||||
|
from .acars import acars_bp
|
||||||
|
from .aprs import aprs_bp
|
||||||
|
from .satellite import satellite_bp
|
||||||
|
from .gps import gps_bp
|
||||||
|
from .settings import settings_bp
|
||||||
|
from .correlation import correlation_bp
|
||||||
|
from .listening_post import listening_post_bp
|
||||||
|
from .meshtastic import meshtastic_bp
|
||||||
|
from .tscm import tscm_bp, init_tscm_state
|
||||||
|
from .spy_stations import spy_stations_bp
|
||||||
|
from .controller import controller_bp
|
||||||
|
from .offline import offline_bp
|
||||||
|
from .updater import updater_bp
|
||||||
|
from .sstv import sstv_bp
|
||||||
|
from .sstv_general import sstv_general_bp
|
||||||
|
from .dmr import dmr_bp
|
||||||
|
from .websdr import websdr_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -60,13 +41,12 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(ais_bp)
|
app.register_blueprint(ais_bp)
|
||||||
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||||
app.register_blueprint(acars_bp)
|
app.register_blueprint(acars_bp)
|
||||||
app.register_blueprint(vdl2_bp)
|
|
||||||
app.register_blueprint(aprs_bp)
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(receiver_bp)
|
app.register_blueprint(listening_post_bp)
|
||||||
app.register_blueprint(meshtastic_bp)
|
app.register_blueprint(meshtastic_bp)
|
||||||
app.register_blueprint(tscm_bp)
|
app.register_blueprint(tscm_bp)
|
||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
@@ -74,26 +54,9 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(offline_bp) # Offline mode settings
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
app.register_blueprint(updater_bp) # GitHub update checking
|
app.register_blueprint(updater_bp) # GitHub update checking
|
||||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
|
||||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||||
|
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
|
||||||
app.register_blueprint(recordings_bp) # Session recordings
|
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
|
||||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
|
||||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
|
||||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
|
||||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
|
||||||
app.register_blueprint(meteor_bp) # Meteor scatter detection
|
|
||||||
app.register_blueprint(morse_bp) # CW/Morse code decoder
|
|
||||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
|
||||||
app.register_blueprint(system_bp) # System health monitoring
|
|
||||||
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
|
||||||
|
|
||||||
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
|
||||||
if _csrf:
|
|
||||||
for bp in app.blueprints.values():
|
|
||||||
_csrf.exempt(bp)
|
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@@ -13,36 +13,31 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.acars_translator import translate_message
|
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.constants import (
|
from utils.constants import (
|
||||||
PROCESS_START_WAIT,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
)
|
)
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.flight_correlator import get_flight_correlator
|
|
||||||
from utils.logging import sensor_logger as logger
|
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
|
||||||
|
|
||||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||||
|
|
||||||
# Default VHF ACARS frequencies (MHz) - North America primary
|
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||||
DEFAULT_ACARS_FREQUENCIES = [
|
DEFAULT_ACARS_FREQUENCIES = [
|
||||||
'131.550', # Primary worldwide / North America
|
'131.550', # Primary worldwide
|
||||||
'130.025', # North America secondary
|
'130.025', # Secondary USA/Canada
|
||||||
'129.125', # North America tertiary
|
'129.125', # USA
|
||||||
'131.725', # North America (major US carriers)
|
'131.525', # Europe
|
||||||
'131.825', # North America (major US carriers)
|
'131.725', # Europe secondary
|
||||||
]
|
]
|
||||||
|
|
||||||
# Message counter for statistics
|
# Message counter for statistics
|
||||||
@@ -51,7 +46,6 @@ acars_last_message_time = None
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
acars_active_device: int | None = None
|
acars_active_device: int | None = None
|
||||||
acars_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def find_acarsdec():
|
def find_acarsdec():
|
||||||
@@ -127,25 +121,12 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
data['type'] = 'acars'
|
data['type'] = 'acars'
|
||||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
# Enrich with translated label and parsed fields
|
|
||||||
try:
|
|
||||||
translation = translate_message(data)
|
|
||||||
data['label_description'] = translation['label_description']
|
|
||||||
data['message_type'] = translation['message_type']
|
|
||||||
data['parsed'] = translation['parsed']
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Update stats
|
# Update stats
|
||||||
acars_message_count += 1
|
acars_message_count += 1
|
||||||
acars_last_message_time = time.time()
|
acars_last_message_time = time.time()
|
||||||
|
|
||||||
app_module.acars_queue.put(data)
|
app_module.acars_queue.put(data)
|
||||||
|
|
||||||
# Feed flight correlator
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
get_flight_correlator().add_acars_message(data)
|
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -164,23 +145,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
logger.error(f"ACARS stream error: {e}")
|
logger.error(f"ACARS stream error: {e}")
|
||||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global acars_active_device, acars_active_sdr_type
|
global acars_active_device
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(acars_active_device)
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
acars_active_sdr_type = None
|
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/tools')
|
@acars_bp.route('/tools')
|
||||||
@@ -212,16 +194,22 @@ def acars_status() -> Response:
|
|||||||
@acars_bp.route('/start', methods=['POST'])
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
def start_acars() -> Response:
|
def start_acars() -> Response:
|
||||||
"""Start ACARS decoder."""
|
"""Start ACARS decoder."""
|
||||||
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
|
global acars_message_count, acars_last_message_time, acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
return api_error('ACARS decoder already running', 409)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'ACARS decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Check for acarsdec
|
# Check for acarsdec
|
||||||
acarsdec_path = find_acarsdec()
|
acarsdec_path = find_acarsdec()
|
||||||
if not acarsdec_path:
|
if not acarsdec_path:
|
||||||
return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
||||||
|
}), 400
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -231,19 +219,19 @@ def start_acars() -> Response:
|
|||||||
gain = validate_gain(data.get('gain', '40'))
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
# Resolve SDR type for device selection
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||||
if error:
|
if error:
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
acars_active_device = device_int
|
acars_active_device = device_int
|
||||||
acars_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
@@ -261,20 +249,12 @@ def start_acars() -> Response:
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
|
||||||
|
|
||||||
# Build acarsdec command
|
# Build acarsdec command
|
||||||
# Different forks have different syntax:
|
# Different forks have different syntax:
|
||||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
|
# Note: gain/ppm must come BEFORE -r
|
||||||
# Note: gain/ppm must come BEFORE -r/-d
|
|
||||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
cmd = [acarsdec_path]
|
cmd = [acarsdec_path]
|
||||||
if json_flag == '--output':
|
if json_flag == '--output':
|
||||||
@@ -285,33 +265,21 @@ def start_acars() -> Response:
|
|||||||
else:
|
else:
|
||||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||||
|
|
||||||
# Add gain if not auto (must be before -r/-d)
|
# Add gain if not auto (must be before -r)
|
||||||
if gain and str(gain) != '0':
|
if gain and str(gain) != '0':
|
||||||
cmd.extend(['-g', str(gain)])
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
# Add PPM correction if specified (must be before -r/-d)
|
# Add PPM correction if specified (must be before -r)
|
||||||
if ppm and str(ppm) != '0':
|
if ppm and str(ppm) != '0':
|
||||||
cmd.extend(['-p', str(ppm)])
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Add device and frequencies
|
# Add device and frequencies
|
||||||
if is_soapy:
|
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
||||||
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
|
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
|
||||||
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
|
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
device_str = builder._build_device_string(sdr_device)
|
|
||||||
if json_flag == '--output':
|
if json_flag == '--output':
|
||||||
cmd.extend(['-m', '256'])
|
|
||||||
cmd.extend(['--soapysdr', device_str])
|
|
||||||
else:
|
|
||||||
cmd.extend(['-d', device_str])
|
|
||||||
elif json_flag == '--output':
|
|
||||||
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
|
|
||||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
cmd.extend(['-m', '256'])
|
cmd.extend(['-m', '256'])
|
||||||
cmd.extend(['--rtlsdr', str(device)])
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
else:
|
else:
|
||||||
# TLeconte fork RTL-SDR: -r <device>
|
|
||||||
cmd.extend(['-r', str(device)])
|
cmd.extend(['-r', str(device)])
|
||||||
cmd.extend(frequencies)
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
@@ -331,7 +299,7 @@ def start_acars() -> Response:
|
|||||||
)
|
)
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
# Wrap master_fd as a text file for line-buffered reading
|
# Wrap master_fd as a text file for line-buffered reading
|
||||||
process.stdout = open(master_fd, buffering=1)
|
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||||
is_text_mode = True
|
is_text_mode = True
|
||||||
else:
|
else:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
@@ -347,19 +315,16 @@ def start_acars() -> Response:
|
|||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died - release device
|
# Process died - release device
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(acars_active_device)
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
acars_active_sdr_type = None
|
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
|
error_msg = f'acarsdec failed to start'
|
||||||
if stderr:
|
if stderr:
|
||||||
logger.error(f"acarsdec stderr:\n{stderr}")
|
error_msg += f': {stderr[:200]}'
|
||||||
error_msg = 'acarsdec failed to start'
|
|
||||||
if stderr:
|
|
||||||
error_msg += f': {stderr[:500]}'
|
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return api_error(error_msg, 500)
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
app_module.acars_process = process
|
app_module.acars_process = process
|
||||||
register_process(process)
|
register_process(process)
|
||||||
@@ -382,21 +347,23 @@ def start_acars() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(acars_active_device)
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
acars_active_sdr_type = None
|
|
||||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/stop', methods=['POST'])
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
def stop_acars() -> Response:
|
def stop_acars() -> Response:
|
||||||
"""Stop ACARS decoder."""
|
"""Stop ACARS decoder."""
|
||||||
global acars_active_device, acars_active_sdr_type
|
global acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if not app_module.acars_process:
|
if not app_module.acars_process:
|
||||||
return api_error('ACARS decoder not running', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'ACARS decoder not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.acars_process.terminate()
|
app_module.acars_process.terminate()
|
||||||
@@ -410,9 +377,8 @@ def stop_acars() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(acars_active_device)
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
acars_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -420,50 +386,33 @@ def stop_acars() -> Response:
|
|||||||
@acars_bp.route('/stream')
|
@acars_bp.route('/stream')
|
||||||
def stream_acars() -> Response:
|
def stream_acars() -> Response:
|
||||||
"""SSE stream for ACARS messages."""
|
"""SSE stream for ACARS messages."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('acars', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=app_module.acars_queue,
|
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
channel_key='acars',
|
last_keepalive = time.time()
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
yield format_sse(msg)
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/messages')
|
|
||||||
def get_acars_messages() -> Response:
|
|
||||||
"""Get recent ACARS messages from correlator (for history reload)."""
|
|
||||||
limit = request.args.get('limit', 50, type=int)
|
|
||||||
limit = max(1, min(limit, 200))
|
|
||||||
msgs = get_flight_correlator().get_recent_messages('acars', limit)
|
|
||||||
return jsonify(msgs)
|
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/clear', methods=['POST'])
|
|
||||||
def clear_acars_messages() -> Response:
|
|
||||||
"""Clear stored ACARS messages and reset counter."""
|
|
||||||
global acars_message_count, acars_last_message_time
|
|
||||||
get_flight_correlator().clear_acars()
|
|
||||||
acars_message_count = 0
|
|
||||||
acars_last_message_time = None
|
|
||||||
return jsonify({'status': 'cleared'})
|
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/frequencies')
|
@acars_bp.route('/frequencies')
|
||||||
def get_frequencies() -> Response:
|
def get_frequencies() -> Response:
|
||||||
"""Get default ACARS frequencies."""
|
"""Get default ACARS frequencies."""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||||
'regions': {
|
'regions': {
|
||||||
'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
|
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||||
'europe': ['131.525', '131.725', '131.550'],
|
'europe': ['131.525', '131.725', '131.550'],
|
||||||
'asia_pacific': ['131.550', '131.450'],
|
'asia_pacific': ['131.550', '131.450'],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -11,28 +10,28 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, render_template, request
|
from flask import Blueprint, jsonify, request, Response, render_template
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.validation import validate_device_index, validate_gain
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
AIS_RECONNECT_DELAY,
|
|
||||||
AIS_SOCKET_TIMEOUT,
|
|
||||||
AIS_TCP_PORT,
|
AIS_TCP_PORT,
|
||||||
AIS_TERMINATE_TIMEOUT,
|
AIS_TERMINATE_TIMEOUT,
|
||||||
|
AIS_SOCKET_TIMEOUT,
|
||||||
|
AIS_RECONNECT_DELAY,
|
||||||
AIS_UPDATE_INTERVAL,
|
AIS_UPDATE_INTERVAL,
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
SOCKET_BUFFER_SIZE,
|
SOCKET_BUFFER_SIZE,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SOCKET_CONNECT_TIMEOUT,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
)
|
)
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import validate_device_index, validate_gain
|
|
||||||
|
|
||||||
logger = get_logger('intercept.ais')
|
logger = get_logger('intercept.ais')
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ ais_connected = False
|
|||||||
ais_messages_received = 0
|
ais_messages_received = 0
|
||||||
ais_last_message_time = None
|
ais_last_message_time = None
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
ais_active_sdr_type: str | None = None
|
|
||||||
_ais_error_logged = True
|
_ais_error_logged = True
|
||||||
|
|
||||||
# Common installation paths for AIS-catcher
|
# Common installation paths for AIS-catcher
|
||||||
@@ -80,7 +78,6 @@ def parse_ais_stream(port: int):
|
|||||||
_ais_error_logged = True
|
_ais_error_logged = True
|
||||||
|
|
||||||
while ais_running:
|
while ais_running:
|
||||||
sock = None
|
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||||
@@ -126,24 +123,12 @@ def parse_ais_stream(port: int):
|
|||||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||||
for mmsi in pending_updates:
|
for mmsi in pending_updates:
|
||||||
if mmsi in app_module.ais_vessels:
|
if mmsi in app_module.ais_vessels:
|
||||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
try:
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
app_module.ais_queue.put_nowait({
|
app_module.ais_queue.put_nowait({
|
||||||
'type': 'vessel',
|
'type': 'vessel',
|
||||||
**_vessel_snap
|
**app_module.ais_vessels[mmsi]
|
||||||
})
|
})
|
||||||
# Geofence check
|
except queue.Full:
|
||||||
_v_lat = _vessel_snap.get('lat')
|
|
||||||
_v_lon = _vessel_snap.get('lon')
|
|
||||||
if _v_lat and _v_lon:
|
|
||||||
try:
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
for _gf_evt in get_geofence_manager().check_position(
|
|
||||||
mmsi, 'vessel', _v_lat, _v_lon,
|
|
||||||
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
|
||||||
):
|
|
||||||
process_event('ais', _gf_evt, 'geofence')
|
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
pending_updates.clear()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
@@ -151,6 +136,7 @@ def parse_ais_stream(port: int):
|
|||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
sock.close()
|
||||||
ais_connected = False
|
ais_connected = False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
ais_connected = False
|
ais_connected = False
|
||||||
@@ -158,10 +144,6 @@ def parse_ais_stream(port: int):
|
|||||||
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||||
_ais_error_logged = True
|
_ais_error_logged = True
|
||||||
time.sleep(AIS_RECONNECT_DELAY)
|
time.sleep(AIS_RECONNECT_DELAY)
|
||||||
finally:
|
|
||||||
if sock:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
ais_connected = False
|
ais_connected = False
|
||||||
logger.info("AIS stream parser stopped")
|
logger.info("AIS stream parser stopped")
|
||||||
@@ -299,16 +281,6 @@ def process_ais_message(msg: dict) -> dict | None:
|
|||||||
# Timestamp
|
# Timestamp
|
||||||
vessel['last_seen'] = time.time()
|
vessel['last_seen'] = time.time()
|
||||||
|
|
||||||
# Check for DSC DISTRESS matching this MMSI
|
|
||||||
try:
|
|
||||||
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
|
||||||
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
|
||||||
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
|
||||||
vessel['dsc_distress'] = True
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return vessel
|
return vessel
|
||||||
|
|
||||||
|
|
||||||
@@ -353,11 +325,11 @@ def ais_status():
|
|||||||
@ais_bp.route('/start', methods=['POST'])
|
@ais_bp.route('/start', methods=['POST'])
|
||||||
def start_ais():
|
def start_ais():
|
||||||
"""Start AIS tracking."""
|
"""Start AIS tracking."""
|
||||||
global ais_running, ais_active_device, ais_active_sdr_type
|
global ais_running, ais_active_device
|
||||||
|
|
||||||
with app_module.ais_lock:
|
with app_module.ais_lock:
|
||||||
if ais_running:
|
if ais_running:
|
||||||
return api_error('AIS tracking already active', 409)
|
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -366,12 +338,15 @@ def start_ais():
|
|||||||
gain = int(validate_gain(data.get('gain', '40')))
|
gain = int(validate_gain(data.get('gain', '40')))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
# Find AIS-catcher
|
# Find AIS-catcher
|
||||||
ais_catcher_path = find_ais_catcher()
|
ais_catcher_path = find_ais_catcher()
|
||||||
if not ais_catcher_path:
|
if not ais_catcher_path:
|
||||||
return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Get SDR type from request
|
# Get SDR type from request
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
@@ -397,9 +372,13 @@ def start_ais():
|
|||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||||
if error:
|
if error:
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Build command using SDR abstraction
|
# Build command using SDR abstraction
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
@@ -432,21 +411,20 @@ def start_ais():
|
|||||||
|
|
||||||
if app_module.ais_process.poll() is not None:
|
if app_module.ais_process.poll() is not None:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.ais_process.stderr:
|
if app_module.ais_process.stderr:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
if stderr_output:
|
except Exception:
|
||||||
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
|
pass
|
||||||
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f' Error: {stderr_output[:500]}'
|
error_msg += f' Error: {stderr_output[:200]}'
|
||||||
return api_error(error_msg, 500)
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
ais_running = True
|
ais_running = True
|
||||||
ais_active_device = device
|
ais_active_device = device
|
||||||
ais_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Start TCP parser thread
|
# Start TCP parser thread
|
||||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||||
@@ -460,15 +438,15 @@ def start_ais():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int)
|
||||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@ais_bp.route('/stop', methods=['POST'])
|
@ais_bp.route('/stop', methods=['POST'])
|
||||||
def stop_ais():
|
def stop_ais():
|
||||||
"""Stop AIS tracking."""
|
"""Stop AIS tracking."""
|
||||||
global ais_running, ais_active_device, ais_active_sdr_type
|
global ais_running, ais_active_device
|
||||||
|
|
||||||
with app_module.ais_lock:
|
with app_module.ais_lock:
|
||||||
if app_module.ais_process:
|
if app_module.ais_process:
|
||||||
@@ -487,11 +465,10 @@ def stop_ais():
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if ais_active_device is not None:
|
if ais_active_device is not None:
|
||||||
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(ais_active_device)
|
||||||
|
|
||||||
ais_running = False
|
ais_running = False
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
ais_active_sdr_type = None
|
|
||||||
|
|
||||||
app_module.ais_vessels.clear()
|
app_module.ais_vessels.clear()
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
@@ -500,47 +477,30 @@ def stop_ais():
|
|||||||
@ais_bp.route('/stream')
|
@ais_bp.route('/stream')
|
||||||
def stream_ais():
|
def stream_ais():
|
||||||
"""SSE stream for AIS vessels."""
|
"""SSE stream for AIS vessels."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('ais', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=app_module.ais_queue,
|
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
channel_key='ais',
|
last_keepalive = time.time()
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
yield format_sse(msg)
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@ais_bp.route('/vessel/<mmsi>/dsc')
|
|
||||||
def get_vessel_dsc(mmsi: str):
|
|
||||||
"""Get DSC messages associated with a vessel MMSI."""
|
|
||||||
if not mmsi or not mmsi.isdigit():
|
|
||||||
return api_error('Invalid MMSI', 400)
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
try:
|
|
||||||
for _key, msg in app_module.dsc_messages.items():
|
|
||||||
if str(msg.get('source_mmsi', '')) == mmsi:
|
|
||||||
matches.append(dict(msg))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
|
|
||||||
|
|
||||||
|
|
||||||
@ais_bp.route('/dashboard')
|
@ais_bp.route('/dashboard')
|
||||||
def ais_dashboard():
|
def ais_dashboard():
|
||||||
"""Popout AIS dashboard."""
|
"""Popout AIS dashboard."""
|
||||||
embedded = request.args.get('embedded', 'false') == 'true'
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'ais_dashboard.html',
|
'ais_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
embedded=embedded,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Alerting API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, request
|
|
||||||
|
|
||||||
from utils.alerts import get_alert_manager
|
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
from utils.sse import format_sse
|
|
||||||
|
|
||||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules', methods=['GET'])
|
|
||||||
def list_rules():
|
|
||||||
manager = get_alert_manager()
|
|
||||||
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
|
||||||
return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules', methods=['POST'])
|
|
||||||
def create_rule():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
if not isinstance(data.get('match', {}), dict):
|
|
||||||
return api_error('match must be a JSON object', 400)
|
|
||||||
|
|
||||||
manager = get_alert_manager()
|
|
||||||
rule_id = manager.add_rule(data)
|
|
||||||
return api_success(data={'rule_id': rule_id})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
|
||||||
def update_rule(rule_id: int):
|
|
||||||
data = request.get_json() or {}
|
|
||||||
manager = get_alert_manager()
|
|
||||||
ok = manager.update_rule(rule_id, data)
|
|
||||||
if not ok:
|
|
||||||
return api_error('Rule not found or no changes', 404)
|
|
||||||
return api_success()
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
|
||||||
def delete_rule(rule_id: int):
|
|
||||||
manager = get_alert_manager()
|
|
||||||
ok = manager.delete_rule(rule_id)
|
|
||||||
if not ok:
|
|
||||||
return api_error('Rule not found', 404)
|
|
||||||
return api_success()
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/events', methods=['GET'])
|
|
||||||
def list_events():
|
|
||||||
manager = get_alert_manager()
|
|
||||||
limit = request.args.get('limit', default=100, type=int)
|
|
||||||
mode = request.args.get('mode')
|
|
||||||
severity = request.args.get('severity')
|
|
||||||
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
|
||||||
return api_success(data={'events': events})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/stream', methods=['GET'])
|
|
||||||
def stream_alerts() -> Response:
|
|
||||||
manager = get_alert_manager()
|
|
||||||
|
|
||||||
def generate() -> Generator[str, None, None]:
|
|
||||||
for event in manager.stream_events(timeout=1.0):
|
|
||||||
yield format_sse(event)
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
|
||||||
@@ -2,50 +2,37 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pty
|
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
import select
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from subprocess import PIPE
|
from subprocess import PIPE, STDOUT
|
||||||
from typing import Any
|
from typing import Generator, Optional
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
|
from utils.sse import format_sse
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_START_WAIT,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
)
|
PROCESS_START_WAIT,
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import sensor_logger as logger
|
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import (
|
|
||||||
validate_device_index,
|
|
||||||
validate_gain,
|
|
||||||
validate_ppm,
|
|
||||||
validate_rtl_tcp_host,
|
|
||||||
validate_rtl_tcp_port,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||||
|
|
||||||
# Track which SDR device is being used
|
# Track which SDR device is being used
|
||||||
aprs_active_device: int | None = None
|
aprs_active_device: int | None = None
|
||||||
aprs_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
# APRS frequencies by region (MHz)
|
# APRS frequencies by region (MHz)
|
||||||
APRS_FREQUENCIES = {
|
APRS_FREQUENCIES = {
|
||||||
@@ -58,8 +45,6 @@ APRS_FREQUENCIES = {
|
|||||||
'brazil': '145.570',
|
'brazil': '145.570',
|
||||||
'japan': '144.640',
|
'japan': '144.640',
|
||||||
'china': '144.640',
|
'china': '144.640',
|
||||||
'iss': '145.825',
|
|
||||||
'sonate2': '145.825',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
@@ -67,7 +52,6 @@ aprs_packet_count = 0
|
|||||||
aprs_station_count = 0
|
aprs_station_count = 0
|
||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {} # callsign -> station data
|
aprs_stations = {} # callsign -> station data
|
||||||
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
|
|
||||||
|
|
||||||
# Meter rate limiting
|
# Meter rate limiting
|
||||||
_last_meter_time = 0.0
|
_last_meter_time = 0.0
|
||||||
@@ -76,27 +60,22 @@ METER_MIN_INTERVAL = 0.1 # Max 10 updates/sec
|
|||||||
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
|
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
|
||||||
|
|
||||||
|
|
||||||
def find_direwolf() -> str | None:
|
def find_direwolf() -> Optional[str]:
|
||||||
"""Find direwolf binary."""
|
"""Find direwolf binary."""
|
||||||
return shutil.which('direwolf')
|
return shutil.which('direwolf')
|
||||||
|
|
||||||
|
|
||||||
def find_multimon_ng() -> str | None:
|
def find_multimon_ng() -> Optional[str]:
|
||||||
"""Find multimon-ng binary."""
|
"""Find multimon-ng binary."""
|
||||||
return shutil.which('multimon-ng')
|
return shutil.which('multimon-ng')
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_fm() -> str | None:
|
def find_rtl_fm() -> Optional[str]:
|
||||||
"""Find rtl_fm binary."""
|
"""Find rtl_fm binary."""
|
||||||
return shutil.which('rtl_fm')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_rx_fm() -> str | None:
|
def find_rtl_power() -> Optional[str]:
|
||||||
"""Find SoapySDR rx_fm binary."""
|
|
||||||
return shutil.which('rx_fm')
|
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_power() -> str | None:
|
|
||||||
"""Find rtl_power binary for spectrum scanning."""
|
"""Find rtl_power binary for spectrum scanning."""
|
||||||
return shutil.which('rtl_power')
|
return shutil.which('rtl_power')
|
||||||
|
|
||||||
@@ -114,36 +93,13 @@ ADEVICE stdin null
|
|||||||
CHANNEL 0
|
CHANNEL 0
|
||||||
MYCALL N0CALL
|
MYCALL N0CALL
|
||||||
MODEM 1200
|
MODEM 1200
|
||||||
FIX_BITS 1
|
|
||||||
AGWPORT 0
|
|
||||||
KISSPORT 0
|
|
||||||
"""
|
"""
|
||||||
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
||||||
f.write(config)
|
f.write(config)
|
||||||
return DIREWOLF_CONFIG_PATH
|
return DIREWOLF_CONFIG_PATH
|
||||||
|
|
||||||
|
|
||||||
def normalize_aprs_output_line(line: str) -> str:
|
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||||
"""Normalize a decoder output line to raw APRS packet format.
|
|
||||||
|
|
||||||
Handles common decoder prefixes:
|
|
||||||
- multimon-ng: ``AFSK1200: ...``
|
|
||||||
- direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc.
|
|
||||||
"""
|
|
||||||
if not line:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
normalized = line.strip()
|
|
||||||
if normalized.startswith('AFSK1200:'):
|
|
||||||
normalized = normalized[9:].strip()
|
|
||||||
|
|
||||||
# Strip one or more leading bracket tags emitted by decoders.
|
|
||||||
# Examples: [0.4], [0L], [NONE]
|
|
||||||
normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def parse_aprs_packet(raw_packet: str) -> dict | None:
|
|
||||||
"""Parse APRS packet into structured data.
|
"""Parse APRS packet into structured data.
|
||||||
|
|
||||||
Supports all major APRS packet types:
|
Supports all major APRS packet types:
|
||||||
@@ -159,15 +115,10 @@ def parse_aprs_packet(raw_packet: str) -> dict | None:
|
|||||||
- User-defined formats
|
- User-defined formats
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
raw_packet = normalize_aprs_output_line(raw_packet)
|
|
||||||
if not raw_packet:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
||||||
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
||||||
|
|
||||||
# Source callsigns can include tactical suffixes like "/1" on some stations.
|
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||||
match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
|
||||||
if not match:
|
if not match:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -432,7 +383,7 @@ def parse_aprs_packet(raw_packet: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_position(data: str) -> dict | None:
|
def parse_position(data: str) -> Optional[dict]:
|
||||||
"""Parse APRS position data."""
|
"""Parse APRS position data."""
|
||||||
try:
|
try:
|
||||||
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
||||||
@@ -483,116 +434,13 @@ def parse_position(data: str) -> dict | None:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Legacy/no-decimal variant occasionally seen in degraded decodes:
|
|
||||||
# DDMMN/DDDMMW (symbol chars still present between/after coords).
|
|
||||||
nodot_match = re.match(
|
|
||||||
r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?',
|
|
||||||
data
|
|
||||||
)
|
|
||||||
if nodot_match:
|
|
||||||
lat_deg = int(nodot_match.group(1))
|
|
||||||
lat_min = float(nodot_match.group(2))
|
|
||||||
lat_dir = nodot_match.group(3)
|
|
||||||
symbol_table = nodot_match.group(4)
|
|
||||||
lon_deg = int(nodot_match.group(5))
|
|
||||||
lon_min = float(nodot_match.group(6))
|
|
||||||
lon_dir = nodot_match.group(7)
|
|
||||||
symbol_code = nodot_match.group(8) or ''
|
|
||||||
|
|
||||||
lat = lat_deg + lat_min / 60.0
|
|
||||||
if lat_dir == 'S':
|
|
||||||
lat = -lat
|
|
||||||
|
|
||||||
lon = lon_deg + lon_min / 60.0
|
|
||||||
if lon_dir == 'W':
|
|
||||||
lon = -lon
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'lat': round(lat, 6),
|
|
||||||
'lon': round(lon, 6),
|
|
||||||
'symbol': symbol_table + symbol_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining = data[13:] if len(data) > 13 else ''
|
|
||||||
|
|
||||||
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
|
||||||
if cs_match:
|
|
||||||
result['course'] = int(cs_match.group(1))
|
|
||||||
result['speed'] = int(cs_match.group(2))
|
|
||||||
|
|
||||||
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
|
||||||
if alt_match:
|
|
||||||
result['altitude'] = int(alt_match.group(1))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Fallback: tolerate APRS ambiguity spaces in minute fields.
|
|
||||||
# Example: 4903. N/07201. W
|
|
||||||
if len(data) >= 18:
|
|
||||||
lat_field = data[0:7]
|
|
||||||
lat_dir = data[7]
|
|
||||||
symbol_table = data[8] if len(data) > 8 else ''
|
|
||||||
lon_field = data[9:17] if len(data) >= 17 else ''
|
|
||||||
lon_dir = data[17] if len(data) > 17 else ''
|
|
||||||
symbol_code = data[18] if len(data) > 18 else ''
|
|
||||||
|
|
||||||
if (
|
|
||||||
len(lat_field) == 7
|
|
||||||
and len(lon_field) == 8
|
|
||||||
and lat_dir in ('N', 'S')
|
|
||||||
and lon_dir in ('E', 'W')
|
|
||||||
):
|
|
||||||
lat_deg_txt = lat_field[:2]
|
|
||||||
lat_min_txt = lat_field[2:].replace(' ', '0')
|
|
||||||
lon_deg_txt = lon_field[:3]
|
|
||||||
lon_min_txt = lon_field[3:].replace(' ', '0')
|
|
||||||
|
|
||||||
if (
|
|
||||||
lat_deg_txt.isdigit()
|
|
||||||
and lon_deg_txt.isdigit()
|
|
||||||
and re.match(r'^\d{2}\.\d+$', lat_min_txt)
|
|
||||||
and re.match(r'^\d{2}\.\d+$', lon_min_txt)
|
|
||||||
):
|
|
||||||
lat_deg = int(lat_deg_txt)
|
|
||||||
lon_deg = int(lon_deg_txt)
|
|
||||||
lat_min = float(lat_min_txt)
|
|
||||||
lon_min = float(lon_min_txt)
|
|
||||||
|
|
||||||
lat = lat_deg + lat_min / 60.0
|
|
||||||
if lat_dir == 'S':
|
|
||||||
lat = -lat
|
|
||||||
|
|
||||||
lon = lon_deg + lon_min / 60.0
|
|
||||||
if lon_dir == 'W':
|
|
||||||
lon = -lon
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'lat': round(lat, 6),
|
|
||||||
'lon': round(lon, 6),
|
|
||||||
'symbol': symbol_table + symbol_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep same extension parsing behavior as primary branch.
|
|
||||||
remaining = data[19:] if len(data) > 19 else ''
|
|
||||||
|
|
||||||
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
|
||||||
if cs_match:
|
|
||||||
result['course'] = int(cs_match.group(1))
|
|
||||||
result['speed'] = int(cs_match.group(2))
|
|
||||||
|
|
||||||
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
|
||||||
if alt_match:
|
|
||||||
result['altitude'] = int(alt_match.group(1))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to parse position: {e}")
|
logger.debug(f"Failed to parse position: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_object(data: str) -> dict | None:
|
def parse_object(data: str) -> Optional[dict]:
|
||||||
"""Parse APRS object data.
|
"""Parse APRS object data.
|
||||||
|
|
||||||
Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
|
Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
|
||||||
@@ -650,7 +498,7 @@ def parse_object(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_item(data: str) -> dict | None:
|
def parse_item(data: str) -> Optional[dict]:
|
||||||
"""Parse APRS item data.
|
"""Parse APRS item data.
|
||||||
|
|
||||||
Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
|
Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
|
||||||
@@ -831,7 +679,7 @@ MIC_E_MESSAGE_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_mic_e(dest: str, data: str) -> dict | None:
|
def parse_mic_e(dest: str, data: str) -> Optional[dict]:
|
||||||
"""Parse Mic-E encoded position from destination and data fields.
|
"""Parse Mic-E encoded position from destination and data fields.
|
||||||
|
|
||||||
Mic-E is a highly compressed format that encodes:
|
Mic-E is a highly compressed format that encodes:
|
||||||
@@ -974,7 +822,7 @@ def parse_mic_e(dest: str, data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_compressed_position(data: str) -> dict | None:
|
def parse_compressed_position(data: str) -> Optional[dict]:
|
||||||
r"""Parse compressed position format (Base-91 encoding).
|
r"""Parse compressed position format (Base-91 encoding).
|
||||||
|
|
||||||
Compressed format: /YYYYXXXX$csT
|
Compressed format: /YYYYXXXX$csT
|
||||||
@@ -1058,7 +906,7 @@ def parse_compressed_position(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_telemetry(data: str) -> dict | None:
|
def parse_telemetry(data: str) -> Optional[dict]:
|
||||||
"""Parse APRS telemetry data.
|
"""Parse APRS telemetry data.
|
||||||
|
|
||||||
Format: T#sss,aaa,aaa,aaa,aaa,aaa,bbbbbbbb
|
Format: T#sss,aaa,aaa,aaa,aaa,aaa,bbbbbbbb
|
||||||
@@ -1123,7 +971,7 @@ def parse_telemetry(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> dict | None:
|
def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> Optional[dict]:
|
||||||
"""Parse telemetry definition messages (PARM, UNIT, EQNS, BITS).
|
"""Parse telemetry definition messages (PARM, UNIT, EQNS, BITS).
|
||||||
|
|
||||||
These messages define the meaning of telemetry values for a station.
|
These messages define the meaning of telemetry values for a station.
|
||||||
@@ -1175,7 +1023,7 @@ def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> di
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_phg(data: str) -> dict | None:
|
def parse_phg(data: str) -> Optional[dict]:
|
||||||
"""Parse PHG (Power/Height/Gain/Directivity) data.
|
"""Parse PHG (Power/Height/Gain/Directivity) data.
|
||||||
|
|
||||||
Format: PHGphgd
|
Format: PHGphgd
|
||||||
@@ -1218,7 +1066,7 @@ def parse_phg(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_rng(data: str) -> dict | None:
|
def parse_rng(data: str) -> Optional[dict]:
|
||||||
"""Parse RNG (radio range) data.
|
"""Parse RNG (radio range) data.
|
||||||
|
|
||||||
Format: RNGrrrr where rrrr is range in miles.
|
Format: RNGrrrr where rrrr is range in miles.
|
||||||
@@ -1232,7 +1080,7 @@ def parse_rng(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_df_report(data: str) -> dict | None:
|
def parse_df_report(data: str) -> Optional[dict]:
|
||||||
"""Parse Direction Finding (DF) report.
|
"""Parse Direction Finding (DF) report.
|
||||||
|
|
||||||
Format: CSE/SPD/BRG/NRQ or similar patterns.
|
Format: CSE/SPD/BRG/NRQ or similar patterns.
|
||||||
@@ -1261,7 +1109,7 @@ def parse_df_report(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_timestamp(data: str) -> dict | None:
|
def parse_timestamp(data: str) -> Optional[dict]:
|
||||||
"""Parse APRS timestamp from position data.
|
"""Parse APRS timestamp from position data.
|
||||||
|
|
||||||
Formats:
|
Formats:
|
||||||
@@ -1305,7 +1153,7 @@ def parse_timestamp(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_third_party(data: str) -> dict | None:
|
def parse_third_party(data: str) -> Optional[dict]:
|
||||||
"""Parse third-party traffic (packets relayed from another network).
|
"""Parse third-party traffic (packets relayed from another network).
|
||||||
|
|
||||||
Format: }CALL>PATH:DATA (the } indicates third-party)
|
Format: }CALL>PATH:DATA (the } indicates third-party)
|
||||||
@@ -1331,7 +1179,7 @@ def parse_third_party(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_user_defined(data: str) -> dict | None:
|
def parse_user_defined(data: str) -> Optional[dict]:
|
||||||
"""Parse user-defined data format.
|
"""Parse user-defined data format.
|
||||||
|
|
||||||
Format: {UUXXXX...
|
Format: {UUXXXX...
|
||||||
@@ -1353,7 +1201,7 @@ def parse_user_defined(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_capabilities(data: str) -> dict | None:
|
def parse_capabilities(data: str) -> Optional[dict]:
|
||||||
"""Parse station capabilities response.
|
"""Parse station capabilities response.
|
||||||
|
|
||||||
Format: <capability1,capability2,...
|
Format: <capability1,capability2,...
|
||||||
@@ -1382,7 +1230,7 @@ def parse_capabilities(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_nmea(data: str) -> dict | None:
|
def parse_nmea(data: str) -> Optional[dict]:
|
||||||
"""Parse raw GPS NMEA sentences.
|
"""Parse raw GPS NMEA sentences.
|
||||||
|
|
||||||
APRS can include raw NMEA data starting with $.
|
APRS can include raw NMEA data starting with $.
|
||||||
@@ -1410,7 +1258,7 @@ def parse_nmea(data: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_audio_level(line: str) -> int | None:
|
def parse_audio_level(line: str) -> Optional[int]:
|
||||||
"""Parse direwolf audio level line and return normalized level (0-100).
|
"""Parse direwolf audio level line and return normalized level (0-100).
|
||||||
|
|
||||||
Direwolf outputs lines like:
|
Direwolf outputs lines like:
|
||||||
@@ -1451,23 +1299,19 @@ def should_send_meter_update(level: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||||
"""Stream decoded APRS packets and audio level meter to queue.
|
"""Stream decoded APRS packets and audio level meter to queue.
|
||||||
|
|
||||||
Reads from a PTY master fd to get line-buffered output from the decoder,
|
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||||
avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
|
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||||
to poll the PTY (same pattern as pager.py).
|
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
||||||
|
|
||||||
Outputs two types of messages to the queue:
|
Outputs two types of messages to the queue:
|
||||||
- type='aprs': Decoded APRS packets
|
- type='aprs': Decoded APRS packets
|
||||||
- type='meter': Audio level meter readings (rate-limited)
|
- type='meter': Audio level meter readings (rate-limited)
|
||||||
"""
|
"""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type
|
global _last_meter_time, _last_meter_level
|
||||||
|
|
||||||
# Capture the device claimed by THIS session so the finally block only
|
|
||||||
# releases our own device, not one claimed by a subsequent start.
|
|
||||||
my_device = aprs_active_device
|
|
||||||
|
|
||||||
# Reset meter state
|
# Reset meter state
|
||||||
_last_meter_time = 0.0
|
_last_meter_time = 0.0
|
||||||
@@ -1476,27 +1320,8 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
|||||||
try:
|
try:
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
# Read from PTY using select() for non-blocking reads.
|
# Read line-by-line in text mode. Empty string '' signals EOF.
|
||||||
# PTY forces the decoder to line-buffer, so output arrives immediately
|
for line in iter(decoder_process.stdout.readline, ''):
|
||||||
# instead of waiting for a full 4-8KB pipe buffer to fill.
|
|
||||||
buffer = ""
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
|
|
||||||
if ready:
|
|
||||||
try:
|
|
||||||
data = os.read(master_fd, 1024)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
buffer += data.decode('utf-8', errors='replace')
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
|
|
||||||
while '\n' in buffer:
|
|
||||||
line, buffer = buffer.split('\n', 1)
|
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
@@ -1513,8 +1338,13 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
|||||||
app_module.aprs_queue.put(meter_msg)
|
app_module.aprs_queue.put(meter_msg)
|
||||||
continue # Audio level lines are not packets
|
continue # Audio level lines are not packets
|
||||||
|
|
||||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
# multimon-ng prefixes decoded packets with "AFSK1200: "
|
||||||
line = normalize_aprs_output_line(line)
|
if line.startswith('AFSK1200:'):
|
||||||
|
line = line[9:].strip()
|
||||||
|
|
||||||
|
# direwolf often prefixes packets with "[0.4] " or similar audio level indicator
|
||||||
|
# Strip any leading bracket prefix like "[0.4] " before parsing
|
||||||
|
line = re.sub(r'^\[\d+\.\d+\]\s*', '', line)
|
||||||
|
|
||||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||||
if '>' not in line or ':' not in line:
|
if '>' not in line or ':' not in line:
|
||||||
@@ -1530,40 +1360,16 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
|||||||
if callsign and callsign not in aprs_stations:
|
if callsign and callsign not in aprs_stations:
|
||||||
aprs_station_count += 1
|
aprs_station_count += 1
|
||||||
|
|
||||||
# Update station data, preserving last known coordinates when
|
# Update station data
|
||||||
# packets do not contain position fields.
|
|
||||||
if callsign:
|
if callsign:
|
||||||
existing = aprs_stations.get(callsign, {})
|
|
||||||
packet_lat = packet.get('lat')
|
|
||||||
packet_lon = packet.get('lon')
|
|
||||||
aprs_stations[callsign] = {
|
aprs_stations[callsign] = {
|
||||||
'callsign': callsign,
|
'callsign': callsign,
|
||||||
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
|
'lat': packet.get('lat'),
|
||||||
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
|
'lon': packet.get('lon'),
|
||||||
'symbol': packet.get('symbol') or existing.get('symbol'),
|
'symbol': packet.get('symbol'),
|
||||||
'last_seen': packet.get('timestamp'),
|
'last_seen': packet.get('timestamp'),
|
||||||
'packet_type': packet.get('packet_type'),
|
'packet_type': packet.get('packet_type'),
|
||||||
}
|
}
|
||||||
# Geofence check
|
|
||||||
_aprs_lat = packet_lat
|
|
||||||
_aprs_lon = packet_lon
|
|
||||||
if _aprs_lat is not None and _aprs_lon is not None:
|
|
||||||
try:
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
for _gf_evt in get_geofence_manager().check_position(
|
|
||||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
|
||||||
{'callsign': callsign}
|
|
||||||
):
|
|
||||||
process_event('aprs', _gf_evt, 'geofence')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Evict oldest stations when limit is exceeded
|
|
||||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
|
||||||
oldest = min(
|
|
||||||
aprs_stations,
|
|
||||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
|
||||||
)
|
|
||||||
del aprs_stations[oldest]
|
|
||||||
|
|
||||||
app_module.aprs_queue.put(packet)
|
app_module.aprs_queue.put(packet)
|
||||||
|
|
||||||
@@ -1580,8 +1386,7 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
|||||||
logger.error(f"APRS stream error: {e}")
|
logger.error(f"APRS stream error: {e}")
|
||||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(OSError):
|
global aprs_active_device
|
||||||
os.close(master_fd)
|
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
# Cleanup processes
|
# Cleanup processes
|
||||||
for proc in [rtl_process, decoder_process]:
|
for proc in [rtl_process, decoder_process]:
|
||||||
@@ -1589,30 +1394,28 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
|
|||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
except Exception:
|
||||||
if my_device is not None and aprs_active_device == my_device:
|
pass
|
||||||
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
# Release SDR device
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/tools')
|
@aprs_bp.route('/tools')
|
||||||
def check_aprs_tools() -> Response:
|
def check_aprs_tools() -> Response:
|
||||||
"""Check for APRS decoding tools."""
|
"""Check for APRS decoding tools."""
|
||||||
has_rtl_fm = find_rtl_fm() is not None
|
has_rtl_fm = find_rtl_fm() is not None
|
||||||
has_rx_fm = find_rx_fm() is not None
|
|
||||||
has_direwolf = find_direwolf() is not None
|
has_direwolf = find_direwolf() is not None
|
||||||
has_multimon = find_multimon_ng() is not None
|
has_multimon = find_multimon_ng() is not None
|
||||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'rtl_fm': has_rtl_fm,
|
'rtl_fm': has_rtl_fm,
|
||||||
'rx_fm': has_rx_fm,
|
|
||||||
'direwolf': has_direwolf,
|
'direwolf': has_direwolf,
|
||||||
'multimon_ng': has_multimon,
|
'multimon_ng': has_multimon,
|
||||||
'ready': has_fm_demod and (has_direwolf or 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)
|
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1642,39 +1445,36 @@ def get_stations() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/data')
|
|
||||||
def aprs_data() -> Response:
|
|
||||||
"""Get APRS data snapshot for remote controller polling compatibility."""
|
|
||||||
running = False
|
|
||||||
if app_module.aprs_process:
|
|
||||||
running = app_module.aprs_process.poll() is None
|
|
||||||
|
|
||||||
return api_success(data={
|
|
||||||
'running': running,
|
|
||||||
'stations': list(aprs_stations.values()),
|
|
||||||
'count': len(aprs_stations),
|
|
||||||
'packet_count': aprs_packet_count,
|
|
||||||
'station_count': aprs_station_count,
|
|
||||||
'last_packet_time': aprs_last_packet_time,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/start', methods=['POST'])
|
@aprs_bp.route('/start', methods=['POST'])
|
||||||
def start_aprs() -> Response:
|
def start_aprs() -> Response:
|
||||||
"""Start APRS decoder."""
|
"""Start APRS decoder."""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
global aprs_active_device, aprs_active_sdr_type
|
global aprs_active_device
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||||
return api_error('APRS decoder already running', 409)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'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)
|
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||||
direwolf_path = find_direwolf()
|
direwolf_path = find_direwolf()
|
||||||
multimon_path = find_multimon_ng()
|
multimon_path = find_multimon_ng()
|
||||||
|
|
||||||
if not direwolf_path and not multimon_path:
|
if not direwolf_path and not multimon_path:
|
||||||
return api_error('No APRS decoder found. Install direwolf or multimon-ng', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||||
|
}), 400
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -1684,32 +1484,17 @@ def start_aprs() -> Response:
|
|||||||
gain = validate_gain(data.get('gain', '40'))
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Reserve SDR device to prevent conflicts with other modes
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
error = app_module.claim_sdr_device(device, 'aprs')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
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 api_error('rtl_fm not found. Install with: sudo apt install rtl-sdr', 400)
|
|
||||||
else:
|
|
||||||
if find_rx_fm() is None:
|
|
||||||
return api_error(f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.', 400)
|
|
||||||
|
|
||||||
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
|
||||||
if not rtl_tcp_host:
|
|
||||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
|
||||||
if error:
|
if error:
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
aprs_active_device = device
|
aprs_active_device = device
|
||||||
aprs_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Get frequency for region
|
# Get frequency for region
|
||||||
region = data.get('region', 'north_america')
|
region = data.get('region', 'north_america')
|
||||||
@@ -1731,39 +1516,28 @@ def start_aprs() -> Response:
|
|||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {}
|
aprs_stations = {}
|
||||||
|
|
||||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
|
||||||
try:
|
freq_hz = f"{float(frequency)}M"
|
||||||
if rtl_tcp_host:
|
rtl_cmd = [
|
||||||
try:
|
rtl_fm_path,
|
||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
'-f', freq_hz,
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
'-M', 'nfm', # Narrowband FM for APRS
|
||||||
except ValueError as e:
|
'-s', '22050', # Sample rate matching direwolf -r 22050
|
||||||
return api_error(str(e), 400)
|
'-E', 'dc', # Enable DC blocking filter for cleaner audio
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
'-A', 'fast', # Fast AGC for packet bursts
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
'-d', str(device),
|
||||||
else:
|
]
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
|
||||||
builder = SDRFactory.get_builder(sdr_device.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] == '-':
|
# Gain: 0 means auto, otherwise set specific gain
|
||||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
if gain and str(gain) != '0':
|
||||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
rtl_cmd.extend(['-g', str(gain)])
|
||||||
except Exception as e:
|
|
||||||
if aprs_active_device is not None:
|
# PPM frequency correction
|
||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
if ppm and str(ppm) != '0':
|
||||||
aprs_active_device = None
|
rtl_cmd.extend(['-p', str(ppm)])
|
||||||
aprs_active_sdr_type = None
|
|
||||||
return api_error(f'Failed to build SDR command: {e}', 500)
|
# Output raw audio to stdout
|
||||||
|
rtl_cmd.append('-')
|
||||||
|
|
||||||
# Build decoder command
|
# Build decoder command
|
||||||
if direwolf_path:
|
if direwolf_path:
|
||||||
@@ -1816,25 +1590,19 @@ def start_aprs() -> Response:
|
|||||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||||
rtl_stderr_thread.start()
|
rtl_stderr_thread.start()
|
||||||
|
|
||||||
# Create a pseudo-terminal for decoder output. PTY forces the
|
|
||||||
# decoder to line-buffer its stdout, avoiding the 15-minute delay
|
|
||||||
# caused by full pipe buffering (~4-8KB) on small APRS packets.
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
|
||||||
|
|
||||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||||
# stdout/stderr go to the PTY slave so output is line-buffered.
|
# Use text mode with line buffering for reliable line-by-line reading.
|
||||||
|
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||||
decoder_process = subprocess.Popen(
|
decoder_process = subprocess.Popen(
|
||||||
decoder_cmd,
|
decoder_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=rtl_process.stdout,
|
||||||
stdout=slave_fd,
|
stdout=PIPE,
|
||||||
stderr=slave_fd,
|
stderr=STDOUT,
|
||||||
close_fds=True,
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
start_new_session=True
|
start_new_session=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Close slave fd in parent — decoder owns it now.
|
|
||||||
os.close(slave_fd)
|
|
||||||
|
|
||||||
# Close rtl_fm's stdout in parent so decoder owns it exclusively.
|
# Close rtl_fm's stdout in parent so decoder owns it exclusively.
|
||||||
# This ensures proper EOF propagation when rtl_fm terminates.
|
# This ensures proper EOF propagation when rtl_fm terminates.
|
||||||
rtl_process.stdout.close()
|
rtl_process.stdout.close()
|
||||||
@@ -1851,55 +1619,43 @@ def start_aprs() -> Response:
|
|||||||
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if stderr_output:
|
|
||||||
logger.error(f"rtl_fm stderr:\n{stderr_output}")
|
|
||||||
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
|
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f': {stderr_output[:500]}'
|
error_msg += f': {stderr_output[:200]}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.close(master_fd)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
decoder_process.kill()
|
|
||||||
if aprs_active_device is not None:
|
|
||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
|
||||||
aprs_active_device = None
|
|
||||||
aprs_active_sdr_type = None
|
|
||||||
return api_error(error_msg, 500)
|
|
||||||
|
|
||||||
if decoder_process.poll() is not None:
|
|
||||||
# Decoder exited early - capture any output from PTY
|
|
||||||
error_output = ''
|
|
||||||
try:
|
try:
|
||||||
ready, _, _ = select.select([master_fd], [], [], 0.5)
|
decoder_process.kill()
|
||||||
if ready:
|
|
||||||
raw = os.read(master_fd, 500)
|
|
||||||
error_output = raw.decode('utf-8', errors='replace')
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
|
aprs_active_device = None
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
|
if decoder_process.poll() is not None:
|
||||||
|
# Decoder exited early - capture any output
|
||||||
|
error_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else ''
|
||||||
error_msg = f'{decoder_name} failed to start'
|
error_msg = f'{decoder_name} failed to start'
|
||||||
if error_output:
|
if error_output:
|
||||||
error_msg += f': {error_output}'
|
error_msg += f': {error_output}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.close(master_fd)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
return api_error(error_msg, 500)
|
|
||||||
|
|
||||||
# Store references for status checks and cleanup
|
# Store references for status checks and cleanup
|
||||||
app_module.aprs_process = decoder_process
|
app_module.aprs_process = decoder_process
|
||||||
app_module.aprs_rtl_process = rtl_process
|
app_module.aprs_rtl_process = rtl_process
|
||||||
app_module.aprs_master_fd = master_fd
|
|
||||||
|
|
||||||
# Start background thread to read decoder output and push to queue
|
# Start background thread to read decoder output and push to queue
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=stream_aprs_output,
|
target=stream_aprs_output,
|
||||||
args=(master_fd, rtl_process, decoder_process),
|
args=(rtl_process, decoder_process),
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -1909,23 +1665,21 @@ def start_aprs() -> Response:
|
|||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'region': region,
|
'region': region,
|
||||||
'device': device,
|
'device': device,
|
||||||
'sdr_type': sdr_type.value,
|
|
||||||
'decoder': decoder_name
|
'decoder': decoder_name
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start APRS decoder: {e}")
|
logger.error(f"Failed to start APRS decoder: {e}")
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
return api_error(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/stop', methods=['POST'])
|
@aprs_bp.route('/stop', methods=['POST'])
|
||||||
def stop_aprs() -> Response:
|
def stop_aprs() -> Response:
|
||||||
"""Stop APRS decoder."""
|
"""Stop APRS decoder."""
|
||||||
global aprs_active_device, aprs_active_sdr_type
|
global aprs_active_device
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
processes_to_stop = []
|
processes_to_stop = []
|
||||||
@@ -1937,7 +1691,10 @@ def stop_aprs() -> Response:
|
|||||||
processes_to_stop.append(app_module.aprs_process)
|
processes_to_stop.append(app_module.aprs_process)
|
||||||
|
|
||||||
if not processes_to_stop:
|
if not processes_to_stop:
|
||||||
return api_error('APRS decoder not running', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'APRS decoder not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
for proc in processes_to_stop:
|
for proc in processes_to_stop:
|
||||||
try:
|
try:
|
||||||
@@ -1948,21 +1705,14 @@ def stop_aprs() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping APRS process: {e}")
|
logger.error(f"Error stopping APRS process: {e}")
|
||||||
|
|
||||||
# Close PTY master fd
|
|
||||||
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.close(app_module.aprs_master_fd)
|
|
||||||
app_module.aprs_master_fd = None
|
|
||||||
|
|
||||||
app_module.aprs_process = None
|
app_module.aprs_process = None
|
||||||
if hasattr(app_module, 'aprs_rtl_process'):
|
if hasattr(app_module, 'aprs_rtl_process'):
|
||||||
app_module.aprs_rtl_process = None
|
app_module.aprs_rtl_process = None
|
||||||
|
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
aprs_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -1970,19 +1720,21 @@ def stop_aprs() -> Response:
|
|||||||
@aprs_bp.route('/stream')
|
@aprs_bp.route('/stream')
|
||||||
def stream_aprs() -> Response:
|
def stream_aprs() -> Response:
|
||||||
"""SSE stream for APRS packets."""
|
"""SSE stream for APRS packets."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('aprs', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=app_module.aprs_queue,
|
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
channel_key='aprs',
|
last_keepalive = time.time()
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
yield format_sse(msg)
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
@@ -2013,7 +1765,10 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
"""
|
"""
|
||||||
rtl_power_path = find_rtl_power()
|
rtl_power_path = find_rtl_power()
|
||||||
if not rtl_power_path:
|
if not rtl_power_path:
|
||||||
return api_error('rtl_power not found. Install with: sudo apt install rtl-sdr', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_power not found. Install with: sudo apt install rtl-sdr'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Get parameters from JSON body or query args
|
# Get parameters from JSON body or query args
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
@@ -2033,7 +1788,7 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
gain = validate_gain(gain)
|
gain = validate_gain(gain)
|
||||||
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
|
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
# Get center frequency
|
# Get center frequency
|
||||||
if frequency:
|
if frequency:
|
||||||
@@ -2078,15 +1833,21 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
|
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
|
||||||
return api_error(f'rtl_power failed: {error_msg}', 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'rtl_power failed: {error_msg}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
# Parse rtl_power CSV output
|
# Parse rtl_power CSV output
|
||||||
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
|
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
|
||||||
if not os.path.exists(tmp_file):
|
if not os.path.exists(tmp_file):
|
||||||
return api_error('rtl_power did not produce output file', 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_power did not produce output file'
|
||||||
|
}), 500
|
||||||
|
|
||||||
bins = []
|
bins = []
|
||||||
with open(tmp_file) as f:
|
with open(tmp_file, 'r') as f:
|
||||||
reader = csv.reader(f)
|
reader = csv.reader(f)
|
||||||
for row in reader:
|
for row in reader:
|
||||||
if len(row) < 7:
|
if len(row) < 7:
|
||||||
@@ -2103,7 +1864,10 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not bins:
|
if not bins:
|
||||||
return api_error('No spectrum data collected. Check SDR connection and antenna.', 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No spectrum data collected. Check SDR connection and antenna.'
|
||||||
|
}), 500
|
||||||
|
|
||||||
# Calculate statistics
|
# Calculate statistics
|
||||||
db_values = [b['db'] for b in bins]
|
db_values = [b['db'] for b in bins]
|
||||||
@@ -2133,7 +1897,8 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
else:
|
else:
|
||||||
advice = "Good signal detected. Decoding should work well."
|
advice = "Good signal detected. Decoding should work well."
|
||||||
|
|
||||||
return api_success(data={
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
'scan_params': {
|
'scan_params': {
|
||||||
'center_freq_mhz': center_freq_mhz,
|
'center_freq_mhz': center_freq_mhz,
|
||||||
'start_freq_mhz': start_freq_mhz,
|
'start_freq_mhz': start_freq_mhz,
|
||||||
@@ -2159,10 +1924,13 @@ def scan_aprs_spectrum() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return api_error(f'Spectrum scan timed out after {duration + 15} seconds', 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Spectrum scan timed out after {duration + 15} seconds'
|
||||||
|
}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Spectrum scan error: {e}")
|
logger.error(f"Spectrum scan error: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
# Cleanup temp file
|
# Cleanup temp file
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"""WebSocket-based audio streaming for SDR."""
|
"""WebSocket-based audio streaming for SDR."""
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# Try to import flask-sock
|
# Try to import flask-sock
|
||||||
@@ -17,8 +15,6 @@ except ImportError:
|
|||||||
WEBSOCKET_AVAILABLE = False
|
WEBSOCKET_AVAILABLE = False
|
||||||
Sock = None
|
Sock = None
|
||||||
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.audio_ws')
|
logger = get_logger('intercept.audio_ws')
|
||||||
@@ -44,12 +40,6 @@ def find_ffmpeg():
|
|||||||
return shutil.which('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():
|
def kill_audio_processes():
|
||||||
"""Kill any running audio processes."""
|
"""Kill any running audio processes."""
|
||||||
global audio_process, rtl_process
|
global audio_process, rtl_process
|
||||||
@@ -59,8 +49,10 @@ def kill_audio_processes():
|
|||||||
audio_process.terminate()
|
audio_process.terminate()
|
||||||
audio_process.wait(timeout=0.5)
|
audio_process.wait(timeout=0.5)
|
||||||
except:
|
except:
|
||||||
with contextlib.suppress(BaseException):
|
try:
|
||||||
audio_process.kill()
|
audio_process.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
audio_process = None
|
audio_process = None
|
||||||
|
|
||||||
if rtl_process:
|
if rtl_process:
|
||||||
@@ -68,8 +60,10 @@ def kill_audio_processes():
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=0.5)
|
rtl_process.wait(timeout=0.5)
|
||||||
except:
|
except:
|
||||||
with contextlib.suppress(BaseException):
|
try:
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
rtl_process = None
|
rtl_process = None
|
||||||
|
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
@@ -111,7 +105,7 @@ def start_audio_stream(config):
|
|||||||
|
|
||||||
rtl_cmd = [
|
rtl_cmd = [
|
||||||
rtl_fm,
|
rtl_fm,
|
||||||
'-M', _rtl_fm_demod_mode(mod),
|
'-M', mod,
|
||||||
'-f', str(freq_hz),
|
'-f', str(freq_hz),
|
||||||
'-s', str(sample_rate),
|
'-s', str(sample_rate),
|
||||||
'-r', str(resample_rate),
|
'-r', str(resample_rate),
|
||||||
@@ -257,13 +251,4 @@ def init_audio_websocket(app: Flask):
|
|||||||
finally:
|
finally:
|
||||||
with process_lock:
|
with process_lock:
|
||||||
kill_audio_processes()
|
kill_audio_processes()
|
||||||
# Complete WebSocket close handshake, then shut down the
|
|
||||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
|
||||||
# on top of the WebSocket stream.
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
ws.close()
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
ws.sock.close()
|
|
||||||
logger.info("WebSocket audio client disconnected")
|
logger.info("WebSocket audio client disconnected")
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import fcntl
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import pty
|
import pty
|
||||||
@@ -12,42 +13,31 @@ import select
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
|
||||||
from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
|
|
||||||
from utils.constants import (
|
|
||||||
SUBPROCESS_TIMEOUT_SHORT,
|
|
||||||
)
|
|
||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.responses import api_error, api_success
|
from utils.sse import format_sse
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
|
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||||
|
from utils.constants import (
|
||||||
|
BT_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SUBPROCESS_TIMEOUT_SHORT,
|
||||||
|
SERVICE_ENUM_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
BT_RESET_DELAY,
|
||||||
|
BT_ADAPTER_DOWN_WAIT,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||||
|
|
||||||
# --- v1 deprecation ---
|
|
||||||
# These endpoints are deprecated in favor of /api/bluetooth/*.
|
|
||||||
# Frontend still uses v1, so they remain active.
|
|
||||||
# Migration: switch frontend to v2 endpoints, then remove this file.
|
|
||||||
_v1_deprecation_logged = set()
|
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.after_request
|
|
||||||
def _add_deprecation_header(response):
|
|
||||||
"""Add X-Deprecated header to all v1 Bluetooth responses."""
|
|
||||||
response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead'
|
|
||||||
endpoint = request.endpoint or ''
|
|
||||||
if endpoint not in _v1_deprecation_logged:
|
|
||||||
_v1_deprecation_logged.add(endpoint)
|
|
||||||
logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*")
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def classify_bt_device(name, device_class, services, manufacturer=None):
|
def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||||
"""Classify Bluetooth device type based on available info."""
|
"""Classify Bluetooth device type based on available info."""
|
||||||
@@ -319,8 +309,10 @@ def stream_bt_scan(process, scan_mode):
|
|||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
|
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
||||||
@@ -338,8 +330,8 @@ def reload_oui_database_route():
|
|||||||
if new_db:
|
if new_db:
|
||||||
OUI_DATABASE.clear()
|
OUI_DATABASE.clear()
|
||||||
OUI_DATABASE.update(new_db)
|
OUI_DATABASE.update(new_db)
|
||||||
return api_success(data={'entries': len(OUI_DATABASE)})
|
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
||||||
return api_error('Could not load oui_database.json')
|
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/interfaces')
|
@bluetooth_bp.route('/interfaces')
|
||||||
@@ -366,7 +358,7 @@ def start_bt_scan():
|
|||||||
with app_module.bt_lock:
|
with app_module.bt_lock:
|
||||||
if app_module.bt_process:
|
if app_module.bt_process:
|
||||||
if app_module.bt_process.poll() is None:
|
if app_module.bt_process.poll() is None:
|
||||||
return api_error('Scan already running')
|
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||||
else:
|
else:
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
|
|
||||||
@@ -378,7 +370,7 @@ def start_bt_scan():
|
|||||||
try:
|
try:
|
||||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
app_module.bt_interface = interface
|
app_module.bt_interface = interface
|
||||||
app_module.bt_devices = {}
|
app_module.bt_devices = {}
|
||||||
@@ -420,14 +412,14 @@ def start_bt_scan():
|
|||||||
os.write(master_fd, b'scan on\n')
|
os.write(master_fd, b'scan on\n')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return api_error(f'Unknown scan mode: {scan_mode}')
|
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if app_module.bt_process.poll() is not None:
|
if app_module.bt_process.poll() is not None:
|
||||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
return api_error(stderr_output or 'Process failed to start')
|
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
|
||||||
|
|
||||||
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
@@ -437,9 +429,9 @@ def start_bt_scan():
|
|||||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return api_error(f'Tool not found: {e.filename}')
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error(str(e))
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||||
@@ -466,7 +458,7 @@ def reset_bt_adapter():
|
|||||||
try:
|
try:
|
||||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
with app_module.bt_lock:
|
with app_module.bt_lock:
|
||||||
if app_module.bt_process:
|
if app_module.bt_process:
|
||||||
@@ -474,8 +466,10 @@ def reset_bt_adapter():
|
|||||||
app_module.bt_process.terminate()
|
app_module.bt_process.terminate()
|
||||||
app_module.bt_process.wait(timeout=2)
|
app_module.bt_process.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
app_module.bt_process.kill()
|
app_module.bt_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -494,12 +488,12 @@ def reset_bt_adapter():
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if is_up else 'warning',
|
'status': 'success' if is_up else 'warning',
|
||||||
'message': f'Adapter {interface} reset' if is_up else 'Reset attempted but adapter may still be down',
|
'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down',
|
||||||
'is_up': is_up
|
'is_up': is_up
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error(str(e))
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||||
@@ -509,7 +503,7 @@ def enum_bt_services():
|
|||||||
target_mac = data.get('mac')
|
target_mac = data.get('mac')
|
||||||
|
|
||||||
if not target_mac:
|
if not target_mac:
|
||||||
return api_error('Target MAC required')
|
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -534,17 +528,18 @@ def enum_bt_services():
|
|||||||
|
|
||||||
app_module.bt_services[target_mac] = services
|
app_module.bt_services[target_mac] = services
|
||||||
|
|
||||||
return api_success(data={
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
'mac': target_mac,
|
'mac': target_mac,
|
||||||
'services': services
|
'services': services
|
||||||
})
|
})
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return api_error('Connection timed out')
|
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return api_error('sdptool not found')
|
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return api_error(str(e))
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/devices')
|
@bluetooth_bp.route('/devices')
|
||||||
@@ -560,19 +555,22 @@ def get_bt_devices():
|
|||||||
@bluetooth_bp.route('/stream')
|
@bluetooth_bp.route('/stream')
|
||||||
def stream_bt():
|
def stream_bt():
|
||||||
"""SSE stream for Bluetooth events."""
|
"""SSE stream for Bluetooth events."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate():
|
||||||
process_event('bluetooth', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=app_module.bt_queue,
|
msg = app_module.bt_queue.get(timeout=1)
|
||||||
channel_key='bluetooth',
|
last_keepalive = time.time()
|
||||||
timeout=1.0,
|
yield format_sse(msg)
|
||||||
keepalive_interval=30.0,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= keepalive_interval:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -7,26 +7,26 @@ aggregation, and heuristics.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request, session
|
||||||
|
|
||||||
from utils.bluetooth import (
|
from utils.bluetooth import (
|
||||||
|
BluetoothScanner,
|
||||||
BTDeviceAggregate,
|
BTDeviceAggregate,
|
||||||
check_capabilities,
|
|
||||||
get_bluetooth_scanner,
|
get_bluetooth_scanner,
|
||||||
|
check_capabilities,
|
||||||
|
RANGE_UNKNOWN,
|
||||||
|
TrackerType,
|
||||||
|
TrackerConfidence,
|
||||||
|
get_tracker_engine,
|
||||||
)
|
)
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
@@ -34,11 +34,6 @@ logger = logging.getLogger('intercept.bluetooth_v2')
|
|||||||
# Blueprint
|
# Blueprint
|
||||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||||
|
|
||||||
# Seen-before tracking
|
|
||||||
_bt_seen_cache: set[str] = set()
|
|
||||||
_bt_session_seen: set[str] = set()
|
|
||||||
_bt_seen_lock = threading.Lock()
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE FUNCTIONS
|
# DATABASE FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -178,13 +173,6 @@ def save_observation_history(device: BTDeviceAggregate) -> None:
|
|||||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||||
|
|
||||||
|
|
||||||
def load_seen_device_ids() -> set[str]:
|
|
||||||
"""Load distinct device IDs from history for seen-before tracking."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
|
||||||
return {row['device_id'] for row in cursor}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API ENDPOINTS
|
# API ENDPOINTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -226,47 +214,22 @@ def start_scan():
|
|||||||
rssi_threshold = data.get('rssi_threshold', -100)
|
rssi_threshold = data.get('rssi_threshold', -100)
|
||||||
|
|
||||||
# Validate mode
|
# Validate mode
|
||||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
||||||
if mode not in valid_modes:
|
if mode not in valid_modes:
|
||||||
return api_error(f'Invalid mode. Must be one of: {valid_modes}', 400)
|
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||||
|
|
||||||
# Get scanner instance
|
# Get scanner instance
|
||||||
scanner = get_bluetooth_scanner(adapter_id)
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
|
|
||||||
# Initialize database tables if needed
|
|
||||||
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
|
# Check if already scanning
|
||||||
if scanner.is_scanning:
|
if scanner.is_scanning:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_scanning',
|
'status': 'already_running',
|
||||||
'scan_status': scanner.get_status().to_dict()
|
'scan_status': scanner.get_status().to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Refresh seen-before cache and reset session set for a new scan
|
# Initialize database tables if needed
|
||||||
with _bt_seen_lock:
|
init_bt_tables()
|
||||||
_bt_seen_cache.clear()
|
|
||||||
_bt_seen_cache.update(load_seen_device_ids())
|
|
||||||
_bt_session_seen.clear()
|
|
||||||
|
|
||||||
# Load active baseline if exists
|
# Load active baseline if exists
|
||||||
baseline_id = get_active_baseline_id()
|
baseline_id = get_active_baseline_id()
|
||||||
@@ -386,7 +349,7 @@ def get_device(device_id: str):
|
|||||||
device = scanner.get_device(device_id)
|
device = scanner.get_device(device_id)
|
||||||
|
|
||||||
if not device:
|
if not device:
|
||||||
return api_error('Device not found', 404)
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
|
||||||
return jsonify(device.to_dict())
|
return jsonify(device.to_dict())
|
||||||
|
|
||||||
@@ -526,7 +489,7 @@ def get_tracker_detail(device_id: str):
|
|||||||
device = scanner.get_device(device_id)
|
device = scanner.get_device(device_id)
|
||||||
|
|
||||||
if not device:
|
if not device:
|
||||||
return api_error('Device not found', 404)
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
|
||||||
# Get RSSI history for timeline
|
# Get RSSI history for timeline
|
||||||
rssi_history = device.get_rssi_history(max_points=100)
|
rssi_history = device.get_rssi_history(max_points=100)
|
||||||
@@ -897,8 +860,6 @@ def stream_events():
|
|||||||
"""Generate SSE events from scanner."""
|
"""Generate SSE events from scanner."""
|
||||||
for event in scanner.stream_events(timeout=1.0):
|
for event in scanner.stream_events(timeout=1.0):
|
||||||
event_name, event_data = map_event_type(event)
|
event_name, event_data = map_event_type(event)
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
process_event('bluetooth', event_data, event_name)
|
|
||||||
yield format_sse(event_data, event=event_name)
|
yield format_sse(event_data, event=event_name)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@@ -966,6 +927,7 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of device dictionaries in TSCM format.
|
List of device dictionaries in TSCM format.
|
||||||
"""
|
"""
|
||||||
|
import time
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
@@ -985,17 +947,6 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
# Convert to TSCM format with tracker detection data
|
# Convert to TSCM format with tracker detection data
|
||||||
tscm_devices = []
|
tscm_devices = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
manufacturer_name = device.manufacturer_name
|
|
||||||
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 = {
|
device_data = {
|
||||||
'mac': device.address,
|
'mac': device.address,
|
||||||
'address_type': device.address_type,
|
'address_type': device.address_type,
|
||||||
@@ -1005,7 +956,7 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
'rssi_median': device.rssi_median,
|
'rssi_median': device.rssi_median,
|
||||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||||
'type': _classify_device_type(device),
|
'type': _classify_device_type(device),
|
||||||
'manufacturer': manufacturer_name,
|
'manufacturer': device.manufacturer_name,
|
||||||
'manufacturer_id': device.manufacturer_id,
|
'manufacturer_id': device.manufacturer_id,
|
||||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||||
'protocol': device.protocol,
|
'protocol': device.protocol,
|
||||||
@@ -1227,30 +1178,6 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
|||||||
"""Classify device type from available data."""
|
"""Classify device type from available data."""
|
||||||
name_lower = (device.name or '').lower()
|
name_lower = (device.name or '').lower()
|
||||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||||
service_uuids = device.service_uuids or []
|
|
||||||
|
|
||||||
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
|
# Check by name patterns
|
||||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||||
@@ -1270,29 +1197,6 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
|||||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||||
return 'media'
|
return 'media'
|
||||||
|
|
||||||
# Tracker signals (metadata or Find My service)
|
|
||||||
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
|
# Check by manufacturer
|
||||||
if 'apple' in manufacturer_lower:
|
if 'apple' in manufacturer_lower:
|
||||||
return 'apple_device'
|
return 'apple_device'
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
"""
|
|
||||||
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.responses import api_error
|
|
||||||
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)
|
|
||||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
|
||||||
- fingerprint_id: Payload fingerprint 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'),
|
|
||||||
device_key=data.get('device_key'),
|
|
||||||
fingerprint_id=data.get('fingerprint_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,
|
|
||||||
target.device_key,
|
|
||||||
target.fingerprint_id,
|
|
||||||
]):
|
|
||||||
return api_error(
|
|
||||||
'At least one target identifier required '
|
|
||||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
|
|
||||||
400
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse environment
|
|
||||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
|
||||||
try:
|
|
||||||
environment = Environment[env_str]
|
|
||||||
except KeyError:
|
|
||||||
return api_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 api_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})"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
session = start_locate_session(
|
|
||||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
|
||||||
)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
|
||||||
return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
|
||||||
return api_error('Failed to start locate session', 500)
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
|
||||||
return jsonify(session.get_status(include_debug=include_debug))
|
|
||||||
|
|
||||||
|
|
||||||
@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 api_error('irk_hex and address are required', 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
irk = bytes.fromhex(irk_hex)
|
|
||||||
except ValueError:
|
|
||||||
return api_error('Invalid IRK hex string', 400)
|
|
||||||
|
|
||||||
if len(irk) != 16:
|
|
||||||
return api_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 api_error('no active session', 400)
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
env_str = data.get('environment', '').upper()
|
|
||||||
try:
|
|
||||||
environment = Environment[env_str]
|
|
||||||
except KeyError:
|
|
||||||
return api_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 api_error('no session')
|
|
||||||
|
|
||||||
scanner = session._scanner
|
|
||||||
if not scanner:
|
|
||||||
return api_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'})
|
|
||||||
@@ -10,60 +10,36 @@ This blueprint provides:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Generator
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
from utils.agent_client import AgentClient, AgentConnectionError, AgentHTTPError, create_client_from_agent
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
create_agent,
|
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||||
delete_agent,
|
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||||
get_agent,
|
)
|
||||||
get_agent_by_name,
|
from utils.agent_client import (
|
||||||
get_recent_payloads,
|
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||||
list_agents,
|
|
||||||
store_push_payload,
|
|
||||||
update_agent,
|
|
||||||
)
|
)
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.trilateration import (
|
from utils.trilateration import (
|
||||||
DeviceLocationTracker,
|
DeviceLocationTracker, PathLossModel, Trilateration,
|
||||||
PathLossModel,
|
AgentObservation, estimate_location_from_observations
|
||||||
Trilateration,
|
|
||||||
estimate_location_from_observations,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.controller')
|
logger = logging.getLogger('intercept.controller')
|
||||||
|
|
||||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
|
||||||
# Multi-agent SSE fanout state (per-client queues).
|
# Multi-agent data queue for combined SSE stream
|
||||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||||
_agent_stream_subscribers_lock = threading.Lock()
|
|
||||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
|
||||||
|
|
||||||
|
|
||||||
def _broadcast_agent_data(payload: dict) -> None:
|
|
||||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
|
||||||
with _agent_stream_subscribers_lock:
|
|
||||||
subscribers = tuple(_agent_stream_subscribers)
|
|
||||||
|
|
||||||
for subscriber in subscribers:
|
|
||||||
try:
|
|
||||||
subscriber.put_nowait(payload)
|
|
||||||
except queue.Full:
|
|
||||||
try:
|
|
||||||
subscriber.get_nowait()
|
|
||||||
subscriber.put_nowait(payload)
|
|
||||||
except (queue.Empty, queue.Full):
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -113,25 +89,28 @@ def register_agent():
|
|||||||
base_url = data.get('base_url', '').strip()
|
base_url = data.get('base_url', '').strip()
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return api_error('Agent name is required', 400)
|
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return api_error('Base URL is required', 400)
|
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||||
|
|
||||||
# Validate URL format
|
# Validate URL format
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(base_url)
|
parsed = urlparse(base_url)
|
||||||
if parsed.scheme not in ('http', 'https'):
|
if parsed.scheme not in ('http', 'https'):
|
||||||
return api_error('URL must start with http:// or https://', 400)
|
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
||||||
if not parsed.netloc:
|
if not parsed.netloc:
|
||||||
return api_error('Invalid URL format', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
except Exception:
|
except Exception:
|
||||||
return api_error('Invalid URL format', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
|
|
||||||
# Check if agent already exists
|
# Check if agent already exists
|
||||||
existing = get_agent_by_name(name)
|
existing = get_agent_by_name(name)
|
||||||
if existing:
|
if existing:
|
||||||
return api_error(f'Agent with name "{name}" already exists', 409)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent with name "{name}" already exists'
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Try to connect and get capabilities
|
# Try to connect and get capabilities
|
||||||
api_key = data.get('api_key', '').strip() or None
|
api_key = data.get('api_key', '').strip() or None
|
||||||
@@ -173,7 +152,7 @@ def register_agent():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to create agent")
|
logger.exception("Failed to create agent")
|
||||||
return api_error(str(e), 500)
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
||||||
@@ -181,7 +160,7 @@ def get_agent_detail(agent_id: int):
|
|||||||
"""Get details of a specific agent."""
|
"""Get details of a specific agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
# Optionally refresh from agent
|
# Optionally refresh from agent
|
||||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
@@ -217,7 +196,7 @@ def update_agent_detail(agent_id: int):
|
|||||||
"""Update an agent's details."""
|
"""Update an agent's details."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -239,7 +218,7 @@ def remove_agent(agent_id: int):
|
|||||||
"""Delete an agent."""
|
"""Delete an agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
delete_agent(agent_id)
|
delete_agent(agent_id)
|
||||||
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
||||||
@@ -250,7 +229,7 @@ def refresh_agent_metadata(agent_id: int):
|
|||||||
"""Refresh an agent's capabilities and status."""
|
"""Refresh an agent's capabilities and status."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -276,10 +255,16 @@ def refresh_agent_metadata(agent_id: int):
|
|||||||
'metadata': metadata
|
'metadata': metadata
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return api_error('Agent is not reachable', 503)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Agent is not reachable'
|
||||||
|
}), 503
|
||||||
|
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return api_error(f'Failed to reach agent: {e}', 503)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to reach agent: {e}'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -291,7 +276,7 @@ def get_agent_status(agent_id: int):
|
|||||||
"""Get an agent's current status including running modes."""
|
"""Get an agent's current status including running modes."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -303,7 +288,10 @@ def get_agent_status(agent_id: int):
|
|||||||
'agent_status': status
|
'agent_status': status
|
||||||
})
|
})
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return api_error(f'Failed to reach agent: {e}', 503)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to reach agent: {e}'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/health', methods=['GET'])
|
@controller_bp.route('/agents/health', methods=['GET'])
|
||||||
@@ -377,7 +365,7 @@ def proxy_start_mode(agent_id: int, mode: str):
|
|||||||
"""Start a mode on a remote agent."""
|
"""Start a mode on a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
params = request.json or {}
|
params = request.json or {}
|
||||||
|
|
||||||
@@ -396,9 +384,15 @@ def proxy_start_mode(agent_id: int, mode: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except AgentConnectionError as e:
|
except AgentConnectionError as e:
|
||||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
except AgentHTTPError as e:
|
except AgentHTTPError as e:
|
||||||
return api_error(f'Agent error: {e}', 502)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
||||||
@@ -406,7 +400,7 @@ def proxy_stop_mode(agent_id: int, mode: str):
|
|||||||
"""Stop a mode on a remote agent."""
|
"""Stop a mode on a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -422,9 +416,15 @@ def proxy_stop_mode(agent_id: int, mode: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except AgentConnectionError as e:
|
except AgentConnectionError as e:
|
||||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
except AgentHTTPError as e:
|
except AgentHTTPError as e:
|
||||||
return api_error(f'Agent error: {e}', 502)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
||||||
@@ -432,7 +432,7 @@ def proxy_mode_status(agent_id: int, mode: str):
|
|||||||
"""Get mode status from a remote agent."""
|
"""Get mode status from a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -446,7 +446,10 @@ def proxy_mode_status(agent_id: int, mode: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return api_error(f'Agent error: {e}', 502)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||||
@@ -454,7 +457,7 @@ def proxy_mode_data(agent_id: int, mode: str):
|
|||||||
"""Get current data from a remote agent."""
|
"""Get current data from a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
@@ -473,7 +476,10 @@ def proxy_mode_data(agent_id: int, mode: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
return api_error(f'Agent error: {e}', 502)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||||
@@ -481,7 +487,7 @@ def proxy_mode_stream(agent_id: int, mode: str):
|
|||||||
"""Proxy SSE stream from a remote agent."""
|
"""Proxy SSE stream from a remote agent."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
client = create_client_from_agent(agent)
|
client = create_client_from_agent(agent)
|
||||||
query = request.query_string.decode('utf-8')
|
query = request.query_string.decode('utf-8')
|
||||||
@@ -522,7 +528,7 @@ def proxy_wifi_monitor(agent_id: int):
|
|||||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Agent not found', 404)
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -557,9 +563,15 @@ def proxy_wifi_monitor(agent_id: int):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except AgentConnectionError as e:
|
except AgentConnectionError as e:
|
||||||
return api_error(f'Cannot connect to agent: {e}', 503)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
except AgentHTTPError as e:
|
except AgentHTTPError as e:
|
||||||
return api_error(f'Agent error: {e}', 502)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -585,23 +597,23 @@ def ingest_push_data():
|
|||||||
"""
|
"""
|
||||||
data = request.json
|
data = request.json
|
||||||
if not data:
|
if not data:
|
||||||
return api_error('No data provided', 400)
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
agent_name = data.get('agent_name')
|
agent_name = data.get('agent_name')
|
||||||
if not agent_name:
|
if not agent_name:
|
||||||
return api_error('agent_name required', 400)
|
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
||||||
|
|
||||||
# Find agent
|
# Find agent
|
||||||
agent = get_agent_by_name(agent_name)
|
agent = get_agent_by_name(agent_name)
|
||||||
if not agent:
|
if not agent:
|
||||||
return api_error('Unknown agent', 401)
|
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
|
||||||
|
|
||||||
# Validate API key if configured
|
# Validate API key if configured
|
||||||
if agent.get('api_key'):
|
if agent.get('api_key'):
|
||||||
provided_key = request.headers.get('X-API-Key', '')
|
provided_key = request.headers.get('X-API-Key', '')
|
||||||
if provided_key != agent['api_key']:
|
if provided_key != agent['api_key']:
|
||||||
logger.warning(f"Invalid API key from agent {agent_name}")
|
logger.warning(f"Invalid API key from agent {agent_name}")
|
||||||
return api_error('Invalid API key', 401)
|
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
|
||||||
|
|
||||||
# Store payload
|
# Store payload
|
||||||
try:
|
try:
|
||||||
@@ -613,8 +625,9 @@ def ingest_push_data():
|
|||||||
received_at=data.get('received_at')
|
received_at=data.get('received_at')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Emit to SSE stream (fanout to all connected clients)
|
# Emit to SSE stream
|
||||||
_broadcast_agent_data({
|
try:
|
||||||
|
agent_data_queue.put_nowait({
|
||||||
'type': 'agent_data',
|
'type': 'agent_data',
|
||||||
'agent_id': agent['id'],
|
'agent_id': agent['id'],
|
||||||
'agent_name': agent_name,
|
'agent_name': agent_name,
|
||||||
@@ -623,6 +636,8 @@ def ingest_push_data():
|
|||||||
'payload': data.get('payload'),
|
'payload': data.get('payload'),
|
||||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
})
|
})
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("Agent data queue full, data may be lost")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'accepted',
|
'status': 'accepted',
|
||||||
@@ -631,7 +646,7 @@ def ingest_push_data():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to store push payload")
|
logger.exception("Failed to store push payload")
|
||||||
return api_error(str(e), 500)
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/api/payloads', methods=['GET'])
|
@controller_bp.route('/api/payloads', methods=['GET'])
|
||||||
@@ -666,18 +681,13 @@ def stream_all_agents():
|
|||||||
This endpoint streams push data as it arrives from agents.
|
This endpoint streams push data as it arrives from agents.
|
||||||
Each message is tagged with agent_id and agent_name.
|
Each message is tagged with agent_id and agent_name.
|
||||||
"""
|
"""
|
||||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
|
||||||
with _agent_stream_subscribers_lock:
|
|
||||||
_agent_stream_subscribers.add(client_queue)
|
|
||||||
|
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = client_queue.get(timeout=1.0)
|
msg = agent_data_queue.get(timeout=1.0)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
@@ -685,9 +695,6 @@ def stream_all_agents():
|
|||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
yield format_sse({'type': 'keepalive'})
|
yield format_sse({'type': 'keepalive'})
|
||||||
last_keepalive = now
|
last_keepalive = now
|
||||||
finally:
|
|
||||||
with _agent_stream_subscribers_lock:
|
|
||||||
_agent_stream_subscribers.discard(client_queue)
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
@@ -704,7 +711,6 @@ def stream_all_agents():
|
|||||||
def agent_management_page():
|
def agent_management_page():
|
||||||
"""Render the agent management page."""
|
"""Render the agent management page."""
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
|
|
||||||
from config import VERSION
|
from config import VERSION
|
||||||
return render_template('agents.html', version=VERSION)
|
return render_template('agents.html', version=VERSION)
|
||||||
|
|
||||||
@@ -753,7 +759,7 @@ def add_location_observation():
|
|||||||
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
||||||
for field in required:
|
for field in required:
|
||||||
if field not in data:
|
if field not in data:
|
||||||
return api_error(f'Missing required field: {field}', 400)
|
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
|
||||||
|
|
||||||
# Look up agent GPS from database if not provided
|
# Look up agent GPS from database if not provided
|
||||||
agent_lat = data.get('agent_lat')
|
agent_lat = data.get('agent_lat')
|
||||||
@@ -767,7 +773,10 @@ def add_location_observation():
|
|||||||
agent_lon = coords.get('lon') or coords.get('longitude')
|
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||||
|
|
||||||
if agent_lat is None or agent_lon is None:
|
if agent_lat is None or agent_lon is None:
|
||||||
return api_error('Agent GPS coordinates required', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Agent GPS coordinates required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
estimate = device_tracker.add_observation(
|
estimate = device_tracker.add_observation(
|
||||||
device_id=data['device_id'],
|
device_id=data['device_id'],
|
||||||
@@ -804,7 +813,10 @@ def estimate_location():
|
|||||||
|
|
||||||
observations = data.get('observations', [])
|
observations = data.get('observations', [])
|
||||||
if len(observations) < 2:
|
if len(observations) < 2:
|
||||||
return api_error('At least 2 observations required', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'At least 2 observations required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
environment = data.get('environment', 'outdoor')
|
environment = data.get('environment', 'outdoor')
|
||||||
|
|
||||||
@@ -816,7 +828,7 @@ def estimate_location():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Location estimation failed")
|
logger.exception("Location estimation failed")
|
||||||
return api_error(str(e), 500)
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
||||||
@@ -868,7 +880,7 @@ def get_devices_near():
|
|||||||
lon = float(request.args.get('lon', 0))
|
lon = float(request.args.get('lon', 0))
|
||||||
radius = float(request.args.get('radius', 100))
|
radius = float(request.args.get('radius', 100))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return api_error('Invalid coordinates', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||||
|
|
||||||
results = device_tracker.get_devices_near(lat, lon, radius)
|
results = device_tracker.get_devices_near(lat, lon, radius)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, Response, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.correlation import get_correlations
|
from utils.correlation import get_correlations
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
|
|
||||||
logger = get_logger('intercept.correlation')
|
logger = get_logger('intercept.correlation')
|
||||||
|
|
||||||
@@ -40,14 +39,18 @@ def get_device_correlations() -> Response:
|
|||||||
include_historical=include_historical
|
include_historical=include_historical
|
||||||
)
|
)
|
||||||
|
|
||||||
return api_success(data={
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
'correlations': correlations,
|
'correlations': correlations,
|
||||||
'wifi_count': len(wifi_devices),
|
'wifi_count': len(wifi_devices),
|
||||||
'bt_count': len(bt_devices)
|
'bt_count': len(bt_devices)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating correlations: {e}")
|
logger.error(f"Error calculating correlations: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@correlation_bp.route('/analyze', methods=['POST'])
|
@correlation_bp.route('/analyze', methods=['POST'])
|
||||||
@@ -64,7 +67,10 @@ def analyze_correlation() -> Response:
|
|||||||
bt_mac = data.get('bt_mac')
|
bt_mac = data.get('bt_mac')
|
||||||
|
|
||||||
if not wifi_mac or not bt_mac:
|
if not wifi_mac or not bt_mac:
|
||||||
return api_error('wifi_mac and bt_mac are required', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'wifi_mac and bt_mac are required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get device data
|
# Get device data
|
||||||
@@ -75,10 +81,16 @@ def analyze_correlation() -> Response:
|
|||||||
bt_device = app_module.bt_devices.get(bt_mac)
|
bt_device = app_module.bt_devices.get(bt_mac)
|
||||||
|
|
||||||
if not wifi_device:
|
if not wifi_device:
|
||||||
return api_error(f'WiFi device {wifi_mac} not found', 404)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'WiFi device {wifi_mac} not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
if not bt_device:
|
if not bt_device:
|
||||||
return api_error(f'Bluetooth device {bt_mac} not found', 404)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Bluetooth device {bt_mac} not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
# Calculate correlation for this specific pair
|
# Calculate correlation for this specific pair
|
||||||
correlations = get_correlations(
|
correlations = get_correlations(
|
||||||
@@ -89,9 +101,19 @@ def analyze_correlation() -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if correlations:
|
if correlations:
|
||||||
return api_success(data={'correlation': correlations[0]})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'correlation': correlations[0]
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
return api_success(data={'correlation': None}, message='No correlation detected between these devices')
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'correlation': None,
|
||||||
|
'message': 'No correlation detected between these devices'
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error analyzing correlation: {e}")
|
logger.error(f"Error analyzing correlation: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
"""DMR / P25 / Digital Voice decoding routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.constants import (
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
QUEUE_MAX_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.dmr')
|
||||||
|
|
||||||
|
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# GLOBAL STATE
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
dmr_rtl_process: Optional[subprocess.Popen] = None
|
||||||
|
dmr_dsd_process: Optional[subprocess.Popen] = None
|
||||||
|
dmr_thread: Optional[threading.Thread] = None
|
||||||
|
dmr_running = False
|
||||||
|
dmr_lock = threading.Lock()
|
||||||
|
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
dmr_active_device: Optional[int] = None
|
||||||
|
|
||||||
|
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||||
|
|
||||||
|
# Classic dsd flags
|
||||||
|
_DSD_PROTOCOL_FLAGS = {
|
||||||
|
'auto': [],
|
||||||
|
'dmr': ['-fd'],
|
||||||
|
'p25': ['-fp'],
|
||||||
|
'nxdn': ['-fn'],
|
||||||
|
'dstar': ['-fi'],
|
||||||
|
'provoice': ['-fv'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# dsd-fme uses different flag names
|
||||||
|
_DSD_FME_PROTOCOL_FLAGS = {
|
||||||
|
'auto': ['-ft'],
|
||||||
|
'dmr': ['-fs'],
|
||||||
|
'p25': ['-f1'],
|
||||||
|
'nxdn': ['-fi'],
|
||||||
|
'dstar': [],
|
||||||
|
'provoice': ['-fp'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# HELPERS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
|
||||||
|
def find_dsd() -> tuple[str | None, bool]:
|
||||||
|
"""Find DSD (Digital Speech Decoder) binary.
|
||||||
|
|
||||||
|
Checks for dsd-fme first (common fork), then falls back to dsd.
|
||||||
|
Returns (path, is_fme) tuple.
|
||||||
|
"""
|
||||||
|
path = shutil.which('dsd-fme')
|
||||||
|
if path:
|
||||||
|
return path, True
|
||||||
|
path = shutil.which('dsd')
|
||||||
|
if path:
|
||||||
|
return path, False
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def find_rtl_fm() -> str | None:
|
||||||
|
"""Find rtl_fm binary."""
|
||||||
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dsd_output(line: str) -> dict | None:
|
||||||
|
"""Parse a line of DSD stderr output into a structured event.
|
||||||
|
|
||||||
|
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
|
||||||
|
different formatting for talkgroup / source / voice frame lines.
|
||||||
|
"""
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
||||||
|
return None
|
||||||
|
|
||||||
|
ts = datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
|
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||||
|
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
||||||
|
if sync_match:
|
||||||
|
return {
|
||||||
|
'type': 'sync',
|
||||||
|
'protocol': sync_match.group(1).strip(),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
|
||||||
|
# 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"
|
||||||
|
tg_match = re.search(
|
||||||
|
r'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if tg_match:
|
||||||
|
result = {
|
||||||
|
'type': 'call',
|
||||||
|
'talkgroup': int(tg_match.group(1)),
|
||||||
|
'source_id': int(tg_match.group(2)),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
# Extract slot if present on the same line
|
||||||
|
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||||
|
if slot_inline:
|
||||||
|
result['slot'] = int(slot_inline.group(1))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# P25 NAC (Network Access Code) — check before voice/slot
|
||||||
|
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
|
||||||
|
if nac_match:
|
||||||
|
return {
|
||||||
|
'type': 'nac',
|
||||||
|
'nac': nac_match.group(1),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Voice frame detection — check BEFORE bare slot match
|
||||||
|
# Classic dsd: "Voice" keyword in frame lines
|
||||||
|
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
|
||||||
|
if re.search(r'\bvoice\b', line, re.IGNORECASE):
|
||||||
|
result = {
|
||||||
|
'type': 'voice',
|
||||||
|
'detail': line,
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||||
|
if slot_inline:
|
||||||
|
result['slot'] = int(slot_inline.group(1))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Bare slot info (only when line is *just* slot info, not voice/call)
|
||||||
|
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
|
||||||
|
if slot_match:
|
||||||
|
return {
|
||||||
|
'type': 'slot',
|
||||||
|
'slot': int(slot_match.group(1)),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
|
||||||
|
# Also catches "Closing", "Input", and other lifecycle lines.
|
||||||
|
# Forward as raw so the frontend can show decoder is alive.
|
||||||
|
return {
|
||||||
|
'type': 'raw',
|
||||||
|
'text': line[:200],
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_put(event: dict):
|
||||||
|
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||||
|
try:
|
||||||
|
dmr_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
dmr_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
dmr_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||||
|
"""Read DSD stderr output and push parsed events to the queue.
|
||||||
|
|
||||||
|
Uses select() with a timeout so we can send periodic heartbeat
|
||||||
|
events while readline() would otherwise block indefinitely during
|
||||||
|
silence (no signal being decoded).
|
||||||
|
"""
|
||||||
|
global dmr_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
_queue_put({'type': 'status', 'text': 'started'})
|
||||||
|
last_heartbeat = time.time()
|
||||||
|
|
||||||
|
while dmr_running:
|
||||||
|
if dsd_process.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Wait up to 1s for data on stderr instead of blocking forever
|
||||||
|
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
line = dsd_process.stderr.readline()
|
||||||
|
if not line:
|
||||||
|
if dsd_process.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = parse_dsd_output(text)
|
||||||
|
if parsed:
|
||||||
|
_queue_put(parsed)
|
||||||
|
last_heartbeat = time.time()
|
||||||
|
else:
|
||||||
|
# No stderr output — send heartbeat so frontend knows
|
||||||
|
# decoder is still alive and listening
|
||||||
|
now = time.time()
|
||||||
|
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
|
||||||
|
_queue_put({
|
||||||
|
'type': 'heartbeat',
|
||||||
|
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||||
|
})
|
||||||
|
last_heartbeat = now
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DSD stream error: {e}")
|
||||||
|
finally:
|
||||||
|
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
||||||
|
dmr_running = False
|
||||||
|
# Capture exit info for diagnostics
|
||||||
|
rc = dsd_process.poll()
|
||||||
|
reason = 'stopped'
|
||||||
|
detail = ''
|
||||||
|
if rc is not None and rc != 0:
|
||||||
|
reason = 'crashed'
|
||||||
|
try:
|
||||||
|
remaining = dsd_process.stderr.read(1024)
|
||||||
|
if remaining:
|
||||||
|
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||||
|
# Cleanup 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)
|
||||||
|
dmr_rtl_process = None
|
||||||
|
dmr_dsd_process = None
|
||||||
|
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||||
|
# Release SDR device
|
||||||
|
if dmr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
|
dmr_active_device = None
|
||||||
|
logger.info("DSD stream thread stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# API ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@dmr_bp.route('/tools')
|
||||||
|
def check_tools() -> Response:
|
||||||
|
"""Check for required tools."""
|
||||||
|
dsd_path, _ = find_dsd()
|
||||||
|
rtl_fm = find_rtl_fm()
|
||||||
|
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,
|
||||||
|
'protocols': VALID_PROTOCOLS,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
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:
|
||||||
|
frequency = float(data.get('frequency', 462.5625))
|
||||||
|
gain = int(data.get('gain', 40))
|
||||||
|
device = int(data.get('device', 0))
|
||||||
|
protocol = str(data.get('protocol', 'auto')).lower()
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||||
|
|
||||||
|
if frequency <= 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400
|
||||||
|
|
||||||
|
if protocol not in VALID_PROTOCOLS:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||||
|
|
||||||
|
# Clear stale queue
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
dmr_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
error = app_module.claim_sdr_device(device, 'dmr')
|
||||||
|
if error:
|
||||||
|
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)
|
||||||
|
rtl_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-M', 'fm',
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', '48000',
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
'-l', '1', # squelch level
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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', '-']
|
||||||
|
if is_fme:
|
||||||
|
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
|
else:
|
||||||
|
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
|
|
||||||
|
try:
|
||||||
|
dmr_rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
register_process(dmr_rtl_process)
|
||||||
|
|
||||||
|
dmr_dsd_process = subprocess.Popen(
|
||||||
|
dsd_cmd,
|
||||||
|
stdin=dmr_rtl_process.stdout,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
register_process(dmr_dsd_process)
|
||||||
|
|
||||||
|
# Allow rtl_fm to send directly to dsd
|
||||||
|
dmr_rtl_process.stdout.close()
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
rtl_rc = dmr_rtl_process.poll()
|
||||||
|
dsd_rc = dmr_dsd_process.poll()
|
||||||
|
if rtl_rc is not None or dsd_rc is not None:
|
||||||
|
# Process died — capture stderr for diagnostics
|
||||||
|
rtl_err = ''
|
||||||
|
if dmr_rtl_process.stderr:
|
||||||
|
rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||||
|
dsd_err = ''
|
||||||
|
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}")
|
||||||
|
if dmr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
|
dmr_active_device = None
|
||||||
|
# Surface a clear error to the user
|
||||||
|
detail = rtl_err.strip() or dsd_err.strip()
|
||||||
|
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||||
|
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
|
||||||
|
elif detail:
|
||||||
|
msg = f'Failed to start DSD pipeline: {detail}'
|
||||||
|
else:
|
||||||
|
msg = 'Failed to start DSD pipeline'
|
||||||
|
return jsonify({'status': 'error', 'message': msg}), 500
|
||||||
|
|
||||||
|
# Drain rtl_fm stderr in background to prevent pipe blocking
|
||||||
|
def _drain_rtl_stderr(proc):
|
||||||
|
try:
|
||||||
|
for line in proc.stderr:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
dmr_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'protocol': protocol,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/status')
|
||||||
|
def dmr_status() -> Response:
|
||||||
|
"""Get DMR decoder status."""
|
||||||
|
return jsonify({
|
||||||
|
'running': dmr_running,
|
||||||
|
'device': dmr_active_device,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/stream')
|
||||||
|
def stream_dmr() -> Response:
|
||||||
|
"""SSE stream for DMR decoder events."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
@@ -6,7 +6,7 @@ distress and safety communications per ITU-R M.493.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
@@ -16,36 +16,30 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from datetime import datetime
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
|
DSC_VHF_FREQUENCY_MHZ,
|
||||||
DSC_SAMPLE_RATE,
|
DSC_SAMPLE_RATE,
|
||||||
DSC_TERMINATE_TIMEOUT,
|
DSC_TERMINATE_TIMEOUT,
|
||||||
DSC_VHF_FREQUENCY_MHZ,
|
|
||||||
)
|
)
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
acknowledge_dsc_alert,
|
|
||||||
get_dsc_alert,
|
|
||||||
get_dsc_alert_summary,
|
|
||||||
get_dsc_alerts,
|
|
||||||
store_dsc_alert,
|
store_dsc_alert,
|
||||||
|
get_dsc_alerts,
|
||||||
|
get_dsc_alert,
|
||||||
|
acknowledge_dsc_alert,
|
||||||
|
get_dsc_alert_summary,
|
||||||
)
|
)
|
||||||
from utils.dependencies import get_tool_path
|
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.event_pipeline import process_event
|
from utils.sse import format_sse
|
||||||
from utils.process import register_process, unregister_process
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.dependencies import get_tool_path
|
||||||
from utils.validation import (
|
from utils.process import register_process, unregister_process
|
||||||
validate_device_index,
|
|
||||||
validate_gain,
|
|
||||||
validate_rtl_tcp_host,
|
|
||||||
validate_rtl_tcp_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dsc')
|
logger = logging.getLogger('intercept.dsc')
|
||||||
|
|
||||||
@@ -56,7 +50,6 @@ dsc_running = False
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
dsc_active_device: int | None = None
|
dsc_active_device: int | None = None
|
||||||
dsc_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_dsc_decoder_path() -> str | None:
|
def _get_dsc_decoder_path() -> str | None:
|
||||||
@@ -82,8 +75,8 @@ def _check_dsc_tools() -> dict:
|
|||||||
# Check for scipy/numpy (needed for decoder)
|
# Check for scipy/numpy (needed for decoder)
|
||||||
scipy_available = False
|
scipy_available = False
|
||||||
try:
|
try:
|
||||||
import numpy
|
|
||||||
import scipy
|
import scipy
|
||||||
|
import numpy
|
||||||
scipy_available = True
|
scipy_available = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@@ -177,9 +170,11 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
global dsc_active_device, dsc_active_sdr_type
|
global dsc_active_device
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
dsc_running = False
|
dsc_running = False
|
||||||
# Cleanup both processes
|
# Cleanup both processes
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
@@ -190,8 +185,10 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
unregister_process(proc)
|
unregister_process(proc)
|
||||||
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
@@ -199,9 +196,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
app_module.dsc_rtl_process = None
|
app_module.dsc_rtl_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
dsc_active_sdr_type = None
|
|
||||||
|
|
||||||
|
|
||||||
def _store_critical_alert(msg: dict) -> None:
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
@@ -334,23 +330,10 @@ def start_decoding() -> Response:
|
|||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Get SDR type from request
|
# Check if device is available using centralized registry
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
global dsc_active_device
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
# Check if device is available using centralized registry (skip for remote rtl_tcp)
|
|
||||||
global dsc_active_device, dsc_active_sdr_type
|
|
||||||
if not rtl_tcp_host:
|
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -359,7 +342,6 @@ def start_decoding() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
dsc_active_device = device_int
|
dsc_active_device = device_int
|
||||||
dsc_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.dsc_queue.empty():
|
while not app_module.dsc_queue.empty():
|
||||||
@@ -368,32 +350,22 @@ def start_decoding() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Build rtl_fm command via SDR abstraction layer
|
# Build rtl_fm command
|
||||||
|
rtl_fm_path = tools['rtl_fm']['path']
|
||||||
decoder_path = tools['dsc_decoder']['path']
|
decoder_path = tools['dsc_decoder']['path']
|
||||||
|
|
||||||
if rtl_tcp_host:
|
# rtl_fm command for DSC decoding
|
||||||
try:
|
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_cmd = [
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_fm_path,
|
||||||
except ValueError as e:
|
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||||
return api_error(str(e), 400)
|
'-s', str(DSC_SAMPLE_RATE),
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
'-d', str(device),
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
'-g', str(gain),
|
||||||
else:
|
'-M', 'fm', # FM demodulation
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
|
'-l', '0', # No squelch for DSC
|
||||||
|
'-E', 'dc' # DC blocking filter
|
||||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
]
|
||||||
rtl_cmd = list(builder.build_fm_demod_command(
|
|
||||||
device=sdr_device,
|
|
||||||
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
|
|
||||||
sample_rate=DSC_SAMPLE_RATE,
|
|
||||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
|
||||||
modulation='fm',
|
|
||||||
squelch=0,
|
|
||||||
))
|
|
||||||
# Ensure trailing '-' for stdin piping and add DC blocking filter
|
|
||||||
if rtl_cmd and rtl_cmd[-1] == '-':
|
|
||||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
|
|
||||||
|
|
||||||
# Decoder command
|
# Decoder command
|
||||||
decoder_cmd = [decoder_path]
|
decoder_cmd = [decoder_path]
|
||||||
@@ -461,13 +433,14 @@ def start_decoding() -> Response:
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
dsc_active_sdr_type = None
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Tool not found: {e.filename}'
|
'message': f'Tool not found: {e.filename}'
|
||||||
@@ -478,13 +451,14 @@ def start_decoding() -> Response:
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
dsc_active_sdr_type = None
|
|
||||||
logger.error(f"Failed to start DSC decoder: {e}")
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -495,7 +469,7 @@ def start_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stop', methods=['POST'])
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
"""Stop DSC decoder."""
|
"""Stop DSC decoder."""
|
||||||
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
global dsc_running, dsc_active_device
|
||||||
|
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
if not app_module.dsc_process:
|
if not app_module.dsc_process:
|
||||||
@@ -509,10 +483,12 @@ def stop_decoding() -> Response:
|
|||||||
app_module.dsc_rtl_process.terminate()
|
app_module.dsc_rtl_process.terminate()
|
||||||
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
app_module.dsc_rtl_process.kill()
|
app_module.dsc_rtl_process.kill()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Terminate decoder process
|
# Terminate decoder process
|
||||||
if app_module.dsc_process:
|
if app_module.dsc_process:
|
||||||
@@ -520,19 +496,20 @@ def stop_decoding() -> Response:
|
|||||||
app_module.dsc_process.terminate()
|
app_module.dsc_process.terminate()
|
||||||
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
app_module.dsc_process.kill()
|
app_module.dsc_process.kill()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
app_module.dsc_process = None
|
app_module.dsc_process = None
|
||||||
app_module.dsc_rtl_process = None
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
dsc_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -540,19 +517,22 @@ def stop_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stream')
|
@dsc_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
"""SSE stream for real-time DSC messages."""
|
"""SSE stream for real-time DSC messages."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('dsc', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=app_module.dsc_queue,
|
msg = app_module.dsc_queue.get(timeout=1)
|
||||||
channel_key='dsc',
|
last_keepalive = time.time()
|
||||||
timeout=1.0,
|
yield format_sse(msg)
|
||||||
keepalive_interval=30.0,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= keepalive_interval:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -3,23 +3,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.gps import (
|
|
||||||
GPSPosition,
|
|
||||||
GPSSkyData,
|
|
||||||
detect_gps_devices,
|
|
||||||
get_current_position,
|
|
||||||
get_gps_reader,
|
|
||||||
is_gpsd_running,
|
|
||||||
start_gpsd,
|
|
||||||
start_gpsd_daemon,
|
|
||||||
stop_gps,
|
|
||||||
stop_gpsd_daemon,
|
|
||||||
)
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import format_sse
|
||||||
|
from utils.gps import (
|
||||||
|
get_gps_reader,
|
||||||
|
start_gpsd,
|
||||||
|
stop_gps,
|
||||||
|
get_current_position,
|
||||||
|
GPSPosition,
|
||||||
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -32,24 +29,12 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
def _position_callback(position: GPSPosition) -> None:
|
def _position_callback(position: GPSPosition) -> None:
|
||||||
"""Callback to queue position updates for SSE stream."""
|
"""Callback to queue position updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
_gps_queue.put_nowait(position.to_dict())
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Discard oldest if queue is full
|
# Discard oldest if queue is full
|
||||||
try:
|
try:
|
||||||
_gps_queue.get_nowait()
|
_gps_queue.get_nowait()
|
||||||
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
_gps_queue.put_nowait(position.to_dict())
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _sky_callback(sky: GPSSkyData) -> None:
|
|
||||||
"""Callback to queue sky data updates for SSE stream."""
|
|
||||||
try:
|
|
||||||
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
|
||||||
except queue.Full:
|
|
||||||
try:
|
|
||||||
_gps_queue.get_nowait()
|
|
||||||
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -60,48 +45,37 @@ def auto_connect_gps():
|
|||||||
Automatically connect to gpsd if available.
|
Automatically connect to gpsd if available.
|
||||||
|
|
||||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||||
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
|
||||||
Returns current status if already connected.
|
Returns current status if already connected.
|
||||||
"""
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
# Ensure stream callbacks are attached for this process.
|
|
||||||
reader.add_callback(_position_callback)
|
|
||||||
reader.add_sky_callback(_sky_callback)
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
sky = reader.sky
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': position is not None,
|
'has_fix': position is not None,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None
|
||||||
'sky': sky.to_dict() if sky else None,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Try to connect to gpsd on localhost:2947
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
port = 2947
|
port = 2947
|
||||||
|
|
||||||
# If gpsd isn't running, try to detect a device and start it
|
# First check if gpsd is reachable
|
||||||
if not is_gpsd_running(host, port):
|
try:
|
||||||
devices = detect_gps_devices()
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
if not devices:
|
sock.settimeout(1.0)
|
||||||
|
sock.connect((host, port))
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'unavailable',
|
'status': 'unavailable',
|
||||||
'message': 'No GPS device detected'
|
'message': 'gpsd not running'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Try to start gpsd with the first detected device
|
|
||||||
device_path = devices[0]['path']
|
|
||||||
success, msg = start_gpsd_daemon(device_path, host, port)
|
|
||||||
if not success:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'unavailable',
|
|
||||||
'message': msg,
|
|
||||||
'devices': devices,
|
|
||||||
})
|
|
||||||
logger.info(f"Auto-started gpsd on {device_path}")
|
|
||||||
|
|
||||||
# Clear the queue
|
# Clear the queue
|
||||||
while not _gps_queue.empty():
|
while not _gps_queue.empty():
|
||||||
try:
|
try:
|
||||||
@@ -110,17 +84,14 @@ def auto_connect_gps():
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Start the gpsd client
|
# Start the gpsd client
|
||||||
success = start_gpsd(host, port,
|
success = start_gpsd(host, port, callback=_position_callback)
|
||||||
callback=_position_callback,
|
|
||||||
sky_callback=_sky_callback)
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': False,
|
'has_fix': False,
|
||||||
'position': None,
|
'position': None
|
||||||
'sky': None,
|
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -129,26 +100,14 @@ def auto_connect_gps():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/devices')
|
|
||||||
def list_gps_devices():
|
|
||||||
"""List detected GPS serial devices."""
|
|
||||||
devices = detect_gps_devices()
|
|
||||||
return jsonify({
|
|
||||||
'devices': devices,
|
|
||||||
'gpsd_running': is_gpsd_running(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stop', methods=['POST'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS client and gpsd daemon if we started it."""
|
"""Stop GPS client."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader:
|
if reader:
|
||||||
reader.remove_callback(_position_callback)
|
reader.remove_callback(_position_callback)
|
||||||
reader.remove_sky_callback(_sky_callback)
|
|
||||||
|
|
||||||
stop_gps()
|
stop_gps()
|
||||||
stop_gpsd_daemon()
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -163,18 +122,15 @@ def get_gps_status():
|
|||||||
'running': False,
|
'running': False,
|
||||||
'device': None,
|
'device': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
'sky': None,
|
|
||||||
'error': None,
|
'error': None,
|
||||||
'message': 'GPS client not started'
|
'message': 'GPS client not started'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
sky = reader.sky
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': reader.is_running,
|
'running': reader.is_running,
|
||||||
'device': reader.device_path,
|
'device': reader.device_path,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None,
|
||||||
'sky': sky.to_dict() if sky else None,
|
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||||
'error': reader.error,
|
'error': reader.error,
|
||||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||||
@@ -205,43 +161,25 @@ 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': 'waiting',
|
|
||||||
'running': False,
|
|
||||||
'message': 'GPS client not running'
|
|
||||||
})
|
|
||||||
|
|
||||||
sky = reader.sky
|
|
||||||
if sky:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'ok',
|
|
||||||
'sky': sky.to_dict()
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'waiting',
|
|
||||||
'message': 'Waiting for satellite data'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position and sky updates."""
|
"""SSE stream of GPS position updates."""
|
||||||
response = Response(
|
def generate() -> Generator[str, None, None]:
|
||||||
sse_stream_fanout(
|
last_keepalive = time.time()
|
||||||
source_queue=_gps_queue,
|
keepalive_interval = 30.0
|
||||||
channel_key='gps',
|
|
||||||
timeout=1.0,
|
while True:
|
||||||
keepalive_interval=30.0,
|
try:
|
||||||
),
|
position = _gps_queue.get(timeout=1)
|
||||||
mimetype='text/event-stream',
|
last_keepalive = time.time()
|
||||||
)
|
yield format_sse({'type': 'position', **position})
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -1,523 +0,0 @@
|
|||||||
"""Receiver routes for radio monitoring and frequency scanning.
|
|
||||||
|
|
||||||
This package splits the listening post into sub-modules:
|
|
||||||
scanner - /scanner/*, /presets routes
|
|
||||||
audio - /audio/* routes
|
|
||||||
waterfall - /waterfall/* routes
|
|
||||||
tools - /tools, /signal/guess routes
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
import shutil
|
|
||||||
import signal
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from flask import Blueprint
|
|
||||||
|
|
||||||
from utils.constants import (
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
)
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
|
|
||||||
logger = get_logger('intercept.receiver')
|
|
||||||
|
|
||||||
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
|
|
||||||
|
|
||||||
# Deferred import to avoid circular import at module load time.
|
|
||||||
# app.py -> register_blueprints -> from .listening_post import receiver_bp
|
|
||||||
# must find receiver_bp already defined (above) before this import runs.
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
import app as app_module # noqa: E402
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# GLOBAL STATE
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# Audio demodulation state
|
|
||||||
audio_process = None
|
|
||||||
audio_rtl_process = None
|
|
||||||
audio_lock = threading.Lock()
|
|
||||||
audio_start_lock = threading.Lock()
|
|
||||||
audio_running = False
|
|
||||||
audio_frequency = 0.0
|
|
||||||
audio_modulation = 'fm'
|
|
||||||
audio_source = 'process'
|
|
||||||
audio_start_token = 0
|
|
||||||
|
|
||||||
# Scanner state
|
|
||||||
scanner_thread: threading.Thread | None = None
|
|
||||||
scanner_running = False
|
|
||||||
scanner_lock = threading.Lock()
|
|
||||||
scanner_paused = False
|
|
||||||
scanner_current_freq = 0.0
|
|
||||||
scanner_active_device: int | None = None
|
|
||||||
scanner_active_sdr_type: str = 'rtlsdr'
|
|
||||||
receiver_active_device: int | None = None
|
|
||||||
receiver_active_sdr_type: str = 'rtlsdr'
|
|
||||||
scanner_power_process: subprocess.Popen | None = None
|
|
||||||
scanner_config = {
|
|
||||||
'start_freq': 88.0,
|
|
||||||
'end_freq': 108.0,
|
|
||||||
'step': 0.1,
|
|
||||||
'modulation': 'wfm',
|
|
||||||
'squelch': 0,
|
|
||||||
'dwell_time': 10.0, # Seconds to stay on active frequency
|
|
||||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
|
||||||
'device': 0,
|
|
||||||
'gain': 40,
|
|
||||||
'bias_t': False, # Bias-T power for external LNA
|
|
||||||
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
|
||||||
'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
|
|
||||||
'snr_threshold': 8,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Activity log
|
|
||||||
activity_log: list[dict] = []
|
|
||||||
activity_log_lock = threading.Lock()
|
|
||||||
MAX_LOG_ENTRIES = 500
|
|
||||||
|
|
||||||
# SSE queue for scanner events
|
|
||||||
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
|
|
||||||
|
|
||||||
# Flag to trigger skip from API
|
|
||||||
scanner_skip_signal = False
|
|
||||||
|
|
||||||
# Waterfall / spectrogram state
|
|
||||||
waterfall_process: subprocess.Popen | None = None
|
|
||||||
waterfall_thread: threading.Thread | None = None
|
|
||||||
waterfall_running = False
|
|
||||||
waterfall_lock = threading.Lock()
|
|
||||||
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
|
||||||
waterfall_active_device: int | None = None
|
|
||||||
waterfall_active_sdr_type: str = 'rtlsdr'
|
|
||||||
waterfall_config = {
|
|
||||||
'start_freq': 88.0,
|
|
||||||
'end_freq': 108.0,
|
|
||||||
'bin_size': 10000,
|
|
||||||
'gain': 40,
|
|
||||||
'device': 0,
|
|
||||||
'max_bins': 1024,
|
|
||||||
'interval': 0.4,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# HELPER FUNCTIONS (shared across sub-modules)
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
|
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_fm() -> str | None:
|
|
||||||
"""Find rtl_fm binary."""
|
|
||||||
return shutil.which('rtl_fm')
|
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_power() -> str | None:
|
|
||||||
"""Find rtl_power binary."""
|
|
||||||
return shutil.which('rtl_power')
|
|
||||||
|
|
||||||
|
|
||||||
def find_rx_fm() -> str | None:
|
|
||||||
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
|
||||||
return shutil.which('rx_fm')
|
|
||||||
|
|
||||||
|
|
||||||
def find_ffmpeg() -> str | None:
|
|
||||||
"""Find ffmpeg for audio encoding."""
|
|
||||||
return shutil.which('ffmpeg')
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
|
|
||||||
"""Create a streaming WAV header with unknown data length."""
|
|
||||||
bytes_per_sample = bits_per_sample // 8
|
|
||||||
byte_rate = sample_rate * channels * bytes_per_sample
|
|
||||||
block_align = channels * bytes_per_sample
|
|
||||||
return (
|
|
||||||
b'RIFF'
|
|
||||||
+ struct.pack('<I', 0xFFFFFFFF)
|
|
||||||
+ b'WAVE'
|
|
||||||
+ b'fmt '
|
|
||||||
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
|
|
||||||
+ b'data'
|
|
||||||
+ struct.pack('<I', 0xFFFFFFFF)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
|
||||||
"""Add entry to activity log."""
|
|
||||||
with activity_log_lock:
|
|
||||||
entry = {
|
|
||||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
|
||||||
'type': event_type,
|
|
||||||
'frequency': frequency,
|
|
||||||
'details': details,
|
|
||||||
}
|
|
||||||
activity_log.insert(0, entry)
|
|
||||||
# Trim log
|
|
||||||
while len(activity_log) > MAX_LOG_ENTRIES:
|
|
||||||
activity_log.pop()
|
|
||||||
|
|
||||||
# Also push to SSE queue
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'log',
|
|
||||||
'entry': entry
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _start_audio_stream(
|
|
||||||
frequency: float,
|
|
||||||
modulation: str,
|
|
||||||
*,
|
|
||||||
device: int | None = None,
|
|
||||||
sdr_type: str | None = None,
|
|
||||||
gain: int | None = None,
|
|
||||||
squelch: int | None = None,
|
|
||||||
bias_t: bool | None = None,
|
|
||||||
):
|
|
||||||
"""Start audio streaming at given frequency."""
|
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
|
||||||
|
|
||||||
# Stop existing stream and snapshot config under lock
|
|
||||||
with audio_lock:
|
|
||||||
_stop_audio_stream_internal()
|
|
||||||
|
|
||||||
ffmpeg_path = find_ffmpeg()
|
|
||||||
if not ffmpeg_path:
|
|
||||||
logger.error("ffmpeg not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Snapshot runtime tuning config so the spawned demod command cannot
|
|
||||||
# drift if shared scanner_config changes while startup is in-flight.
|
|
||||||
device_index = int(device if device is not None else scanner_config.get('device', 0))
|
|
||||||
gain_value = int(gain if gain is not None else scanner_config.get('gain', 40))
|
|
||||||
squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0))
|
|
||||||
bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t)
|
|
||||||
sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower()
|
|
||||||
|
|
||||||
# Build commands outside lock (no blocking I/O, just command construction)
|
|
||||||
try:
|
|
||||||
resolved_sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
resolved_sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
# Set sample rates based on modulation
|
|
||||||
if modulation == 'wfm':
|
|
||||||
sample_rate = 170000
|
|
||||||
resample_rate = 32000
|
|
||||||
elif modulation in ['usb', 'lsb']:
|
|
||||||
sample_rate = 12000
|
|
||||||
resample_rate = 12000
|
|
||||||
else:
|
|
||||||
sample_rate = 24000
|
|
||||||
resample_rate = 24000
|
|
||||||
|
|
||||||
# Build the SDR command based on device type
|
|
||||||
if resolved_sdr_type == SDRType.RTL_SDR:
|
|
||||||
rtl_fm_path = find_rtl_fm()
|
|
||||||
if not rtl_fm_path:
|
|
||||||
logger.error("rtl_fm not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
freq_hz = int(frequency * 1e6)
|
|
||||||
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(gain_value),
|
|
||||||
'-d', str(device_index),
|
|
||||||
'-l', str(squelch_value),
|
|
||||||
]
|
|
||||||
if bias_t_enabled:
|
|
||||||
sdr_cmd.append('-T')
|
|
||||||
else:
|
|
||||||
rx_fm_path = find_rx_fm()
|
|
||||||
if not rx_fm_path:
|
|
||||||
logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.")
|
|
||||||
return
|
|
||||||
|
|
||||||
sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index)
|
|
||||||
builder = SDRFactory.get_builder(resolved_sdr_type)
|
|
||||||
sdr_cmd = builder.build_fm_demod_command(
|
|
||||||
device=sdr_device,
|
|
||||||
frequency_mhz=frequency,
|
|
||||||
sample_rate=resample_rate,
|
|
||||||
gain=float(gain_value),
|
|
||||||
modulation=modulation,
|
|
||||||
squelch=squelch_value,
|
|
||||||
bias_t=bias_t_enabled,
|
|
||||||
)
|
|
||||||
sdr_cmd[0] = rx_fm_path
|
|
||||||
|
|
||||||
encoder_cmd = [
|
|
||||||
ffmpeg_path,
|
|
||||||
'-hide_banner',
|
|
||||||
'-loglevel', 'error',
|
|
||||||
'-fflags', 'nobuffer',
|
|
||||||
'-flags', 'low_delay',
|
|
||||||
'-probesize', '32',
|
|
||||||
'-analyzeduration', '0',
|
|
||||||
'-f', 's16le',
|
|
||||||
'-ar', str(resample_rate),
|
|
||||||
'-ac', '1',
|
|
||||||
'-i', 'pipe:0',
|
|
||||||
'-acodec', 'pcm_s16le',
|
|
||||||
'-ar', '44100',
|
|
||||||
'-f', 'wav',
|
|
||||||
'pipe:1'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Retry loop outside lock — spawning + health check sleeps don't block
|
|
||||||
# other operations. audio_start_lock already serializes callers.
|
|
||||||
try:
|
|
||||||
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
|
||||||
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
|
||||||
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}")
|
|
||||||
|
|
||||||
new_rtl_proc = None
|
|
||||||
new_audio_proc = None
|
|
||||||
max_attempts = 3
|
|
||||||
for attempt in range(max_attempts):
|
|
||||||
new_rtl_proc = None
|
|
||||||
new_audio_proc = None
|
|
||||||
rtl_err_handle = None
|
|
||||||
ffmpeg_err_handle = None
|
|
||||||
try:
|
|
||||||
rtl_err_handle = open(rtl_stderr_log, 'w')
|
|
||||||
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
|
|
||||||
new_rtl_proc = subprocess.Popen(
|
|
||||||
sdr_cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=rtl_err_handle,
|
|
||||||
bufsize=0,
|
|
||||||
start_new_session=True
|
|
||||||
)
|
|
||||||
new_audio_proc = subprocess.Popen(
|
|
||||||
encoder_cmd,
|
|
||||||
stdin=new_rtl_proc.stdout,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=ffmpeg_err_handle,
|
|
||||||
bufsize=0,
|
|
||||||
start_new_session=True
|
|
||||||
)
|
|
||||||
if new_rtl_proc.stdout:
|
|
||||||
new_rtl_proc.stdout.close()
|
|
||||||
finally:
|
|
||||||
if rtl_err_handle:
|
|
||||||
rtl_err_handle.close()
|
|
||||||
if ffmpeg_err_handle:
|
|
||||||
ffmpeg_err_handle.close()
|
|
||||||
|
|
||||||
# Brief delay to check if process started successfully
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
if (new_rtl_proc and new_rtl_proc.poll() is not None) or (
|
|
||||||
new_audio_proc and new_audio_proc.poll() is not None
|
|
||||||
):
|
|
||||||
rtl_stderr = ''
|
|
||||||
ffmpeg_stderr = ''
|
|
||||||
try:
|
|
||||||
with open(rtl_stderr_log) as f:
|
|
||||||
rtl_stderr = f.read().strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
with open(ffmpeg_stderr_log) as f:
|
|
||||||
ffmpeg_stderr = f.read().strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
|
|
||||||
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
|
|
||||||
if new_audio_proc:
|
|
||||||
try:
|
|
||||||
new_audio_proc.terminate()
|
|
||||||
new_audio_proc.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if new_rtl_proc:
|
|
||||||
try:
|
|
||||||
new_rtl_proc.terminate()
|
|
||||||
new_rtl_proc.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(1.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if new_audio_proc and new_audio_proc.poll() is None:
|
|
||||||
try:
|
|
||||||
new_audio_proc.terminate()
|
|
||||||
new_audio_proc.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if new_rtl_proc and new_rtl_proc.poll() is None:
|
|
||||||
try:
|
|
||||||
new_rtl_proc.terminate()
|
|
||||||
new_rtl_proc.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
new_audio_proc = None
|
|
||||||
new_rtl_proc = None
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pipeline started successfully
|
|
||||||
break
|
|
||||||
|
|
||||||
# Verify pipeline is still alive, then install under lock
|
|
||||||
if (
|
|
||||||
not new_audio_proc
|
|
||||||
or not new_rtl_proc
|
|
||||||
or new_audio_proc.poll() is not None
|
|
||||||
or new_rtl_proc.poll() is not None
|
|
||||||
):
|
|
||||||
logger.warning("Audio pipeline did not remain alive after startup")
|
|
||||||
# Clean up failed processes
|
|
||||||
if new_audio_proc:
|
|
||||||
try:
|
|
||||||
new_audio_proc.terminate()
|
|
||||||
new_audio_proc.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if new_rtl_proc:
|
|
||||||
try:
|
|
||||||
new_rtl_proc.terminate()
|
|
||||||
new_rtl_proc.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
# Install processes under lock
|
|
||||||
with audio_lock:
|
|
||||||
audio_rtl_process = new_rtl_proc
|
|
||||||
audio_process = new_audio_proc
|
|
||||||
audio_running = True
|
|
||||||
audio_frequency = frequency
|
|
||||||
audio_modulation = modulation
|
|
||||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_sdr_type.value}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start audio stream: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_audio_stream():
|
|
||||||
"""Stop audio streaming."""
|
|
||||||
with audio_lock:
|
|
||||||
_stop_audio_stream_internal()
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_audio_stream_internal():
|
|
||||||
"""Internal stop (must hold lock)."""
|
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
|
|
||||||
|
|
||||||
# Set flag first to stop any streaming
|
|
||||||
audio_running = False
|
|
||||||
audio_frequency = 0.0
|
|
||||||
previous_source = audio_source
|
|
||||||
audio_source = 'process'
|
|
||||||
|
|
||||||
if previous_source == 'waterfall':
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import stop_shared_monitor_from_capture
|
|
||||||
|
|
||||||
stop_shared_monitor_from_capture()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
had_processes = audio_process is not None or audio_rtl_process is not None
|
|
||||||
|
|
||||||
# Kill the pipeline processes and their groups
|
|
||||||
if audio_process:
|
|
||||||
try:
|
|
||||||
# Kill entire process group (SDR demod + ffmpeg)
|
|
||||||
try:
|
|
||||||
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
|
||||||
except (ProcessLookupError, PermissionError):
|
|
||||||
audio_process.kill()
|
|
||||||
audio_process.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if audio_rtl_process:
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL)
|
|
||||||
except (ProcessLookupError, PermissionError):
|
|
||||||
audio_rtl_process.kill()
|
|
||||||
audio_rtl_process.wait(timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
audio_process = None
|
|
||||||
audio_rtl_process = None
|
|
||||||
|
|
||||||
# Brief pause for SDR device USB interface to be released by kernel.
|
|
||||||
# The _start_audio_stream retry loop handles longer contention windows
|
|
||||||
# so only a minimal delay is needed here.
|
|
||||||
if had_processes:
|
|
||||||
time.sleep(0.15)
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_waterfall_internal() -> None:
|
|
||||||
"""Stop the waterfall display and release resources."""
|
|
||||||
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
|
|
||||||
|
|
||||||
waterfall_running = False
|
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
|
||||||
try:
|
|
||||||
waterfall_process.terminate()
|
|
||||||
waterfall_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
waterfall_process.kill()
|
|
||||||
waterfall_process = None
|
|
||||||
|
|
||||||
if waterfall_active_device is not None:
|
|
||||||
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
|
|
||||||
waterfall_active_device = None
|
|
||||||
waterfall_active_sdr_type = 'rtlsdr'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Import sub-modules to register routes on receiver_bp
|
|
||||||
# ============================================
|
|
||||||
from . import (
|
|
||||||
audio, # noqa: E402, F401
|
|
||||||
scanner, # noqa: E402, F401
|
|
||||||
tools, # noqa: E402, F401
|
|
||||||
waterfall, # noqa: E402, F401
|
|
||||||
)
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
"""Audio routes for manual listening and audio streaming."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import select
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
|
|
||||||
from flask import Response, jsonify, request
|
|
||||||
|
|
||||||
import routes.listening_post as _state
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
_start_audio_stream,
|
|
||||||
_stop_audio_stream,
|
|
||||||
_stop_waterfall_internal,
|
|
||||||
_wav_header,
|
|
||||||
app_module,
|
|
||||||
logger,
|
|
||||||
normalize_modulation,
|
|
||||||
receiver_bp,
|
|
||||||
scanner_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
|
||||||
def start_audio() -> Response:
|
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
frequency = float(data.get('frequency', 0))
|
|
||||||
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
|
||||||
squelch = int(data['squelch']) if data.get('squelch') is not None else 0
|
|
||||||
gain = int(data['gain']) if data.get('gain') is not None else 40
|
|
||||||
device = int(data['device']) if data.get('device') is not None else 0
|
|
||||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
|
||||||
request_token_raw = data.get('request_token')
|
|
||||||
request_token = int(request_token_raw) if request_token_raw is not None else None
|
|
||||||
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
|
||||||
if isinstance(bias_t_raw, str):
|
|
||||||
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
|
||||||
else:
|
|
||||||
bias_t = bool(bias_t_raw)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid parameter: {e}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
if frequency <= 0:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'frequency is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
|
||||||
if sdr_type not in valid_sdr_types:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
with _state.audio_start_lock:
|
|
||||||
if request_token is not None:
|
|
||||||
if request_token < _state.audio_start_token:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'stale',
|
|
||||||
'message': 'Superseded audio start request',
|
|
||||||
'source': _state.audio_source,
|
|
||||||
'superseded': True,
|
|
||||||
'current_token': _state.audio_start_token,
|
|
||||||
}), 409
|
|
||||||
_state.audio_start_token = request_token
|
|
||||||
else:
|
|
||||||
_state.audio_start_token += 1
|
|
||||||
request_token = _state.audio_start_token
|
|
||||||
|
|
||||||
# Grab scanner refs inside lock, signal stop, clear state
|
|
||||||
need_scanner_teardown = False
|
|
||||||
scanner_thread_ref = None
|
|
||||||
scanner_proc_ref = None
|
|
||||||
if _state.scanner_running:
|
|
||||||
_state.scanner_running = False
|
|
||||||
if _state.scanner_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
|
|
||||||
_state.scanner_active_device = None
|
|
||||||
_state.scanner_active_sdr_type = 'rtlsdr'
|
|
||||||
scanner_thread_ref = _state.scanner_thread
|
|
||||||
scanner_proc_ref = _state.scanner_power_process
|
|
||||||
_state.scanner_power_process = None
|
|
||||||
need_scanner_teardown = True
|
|
||||||
|
|
||||||
# Update config for audio
|
|
||||||
scanner_config['squelch'] = squelch
|
|
||||||
scanner_config['gain'] = gain
|
|
||||||
scanner_config['device'] = device
|
|
||||||
scanner_config['sdr_type'] = sdr_type
|
|
||||||
scanner_config['bias_t'] = bias_t
|
|
||||||
|
|
||||||
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
|
|
||||||
if need_scanner_teardown:
|
|
||||||
if scanner_thread_ref and scanner_thread_ref.is_alive():
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
scanner_thread_ref.join(timeout=2.0)
|
|
||||||
if scanner_proc_ref and scanner_proc_ref.poll() is None:
|
|
||||||
try:
|
|
||||||
scanner_proc_ref.terminate()
|
|
||||||
scanner_proc_ref.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
scanner_proc_ref.kill()
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# Re-acquire lock for waterfall check and device claim
|
|
||||||
with _state.audio_start_lock:
|
|
||||||
|
|
||||||
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
|
||||||
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import (
|
|
||||||
get_shared_capture_status,
|
|
||||||
start_shared_monitor_from_capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
shared = get_shared_capture_status()
|
|
||||||
if shared.get('running') and shared.get('device') == device:
|
|
||||||
_stop_audio_stream()
|
|
||||||
ok, msg = start_shared_monitor_from_capture(
|
|
||||||
device=device,
|
|
||||||
frequency_mhz=frequency,
|
|
||||||
modulation=modulation,
|
|
||||||
squelch=squelch,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
_state.audio_running = True
|
|
||||||
_state.audio_frequency = frequency
|
|
||||||
_state.audio_modulation = modulation
|
|
||||||
_state.audio_source = 'waterfall'
|
|
||||||
# Shared monitor uses the waterfall's existing SDR claim.
|
|
||||||
if _state.receiver_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
|
||||||
_state.receiver_active_device = None
|
|
||||||
_state.receiver_active_sdr_type = 'rtlsdr'
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'frequency': frequency,
|
|
||||||
'modulation': modulation,
|
|
||||||
'source': 'waterfall',
|
|
||||||
'request_token': request_token,
|
|
||||||
})
|
|
||||||
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
|
||||||
|
|
||||||
# Stop waterfall if it's using the same SDR (SSE path)
|
|
||||||
if _state.waterfall_running and _state.waterfall_active_device == device:
|
|
||||||
_stop_waterfall_internal()
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
# Claim device for listening audio. The WebSocket waterfall handler
|
|
||||||
# may still be tearing down its IQ capture process (thread join +
|
|
||||||
# safe_terminate can take several seconds), so we retry with back-off
|
|
||||||
# to give the USB device time to be fully released.
|
|
||||||
if _state.receiver_active_device is None or _state.receiver_active_device != device:
|
|
||||||
if _state.receiver_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
|
||||||
_state.receiver_active_device = None
|
|
||||||
_state.receiver_active_sdr_type = 'rtlsdr'
|
|
||||||
|
|
||||||
error = None
|
|
||||||
max_claim_attempts = 6
|
|
||||||
for attempt in range(max_claim_attempts):
|
|
||||||
error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
|
|
||||||
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',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
_state.receiver_active_device = device
|
|
||||||
_state.receiver_active_sdr_type = sdr_type
|
|
||||||
|
|
||||||
_start_audio_stream(
|
|
||||||
frequency,
|
|
||||||
modulation,
|
|
||||||
device=device,
|
|
||||||
sdr_type=sdr_type,
|
|
||||||
gain=gain,
|
|
||||||
squelch=squelch,
|
|
||||||
bias_t=bias_t,
|
|
||||||
)
|
|
||||||
|
|
||||||
if _state.audio_running:
|
|
||||||
_state.audio_source = 'process'
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'frequency': _state.audio_frequency,
|
|
||||||
'modulation': _state.audio_modulation,
|
|
||||||
'source': 'process',
|
|
||||||
'request_token': request_token,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Avoid leaving a stale device claim after startup failure.
|
|
||||||
if _state.receiver_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
|
||||||
_state.receiver_active_device = None
|
|
||||||
_state.receiver_active_sdr_type = 'rtlsdr'
|
|
||||||
|
|
||||||
start_error = ''
|
|
||||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
|
||||||
try:
|
|
||||||
with open(log_path) as handle:
|
|
||||||
content = handle.read().strip()
|
|
||||||
if content:
|
|
||||||
start_error = content.splitlines()[-1]
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
message = 'Failed to start audio. Check SDR device.'
|
|
||||||
if start_error:
|
|
||||||
message = f'Failed to start audio: {start_error}'
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': message
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/audio/stop', methods=['POST'])
|
|
||||||
def stop_audio() -> Response:
|
|
||||||
"""Stop audio."""
|
|
||||||
_stop_audio_stream()
|
|
||||||
if _state.receiver_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
|
||||||
_state.receiver_active_device = None
|
|
||||||
_state.receiver_active_sdr_type = 'rtlsdr'
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/audio/status')
|
|
||||||
def audio_status() -> Response:
|
|
||||||
"""Get audio status."""
|
|
||||||
running = _state.audio_running
|
|
||||||
if _state.audio_source == 'waterfall':
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import get_shared_capture_status
|
|
||||||
|
|
||||||
shared = get_shared_capture_status()
|
|
||||||
running = bool(shared.get('running') and shared.get('monitor_enabled'))
|
|
||||||
except Exception:
|
|
||||||
running = False
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'running': running,
|
|
||||||
'frequency': _state.audio_frequency,
|
|
||||||
'modulation': _state.audio_modulation,
|
|
||||||
'source': _state.audio_source,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/audio/debug')
|
|
||||||
def audio_debug() -> Response:
|
|
||||||
"""Get audio debug status and recent stderr logs."""
|
|
||||||
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
|
||||||
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
|
|
||||||
sample_path = '/tmp/audio_probe.bin'
|
|
||||||
|
|
||||||
def _read_log(path: str) -> str:
|
|
||||||
try:
|
|
||||||
with open(path) as handle:
|
|
||||||
return handle.read().strip()
|
|
||||||
except Exception:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
shared = {}
|
|
||||||
if _state.audio_source == 'waterfall':
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import get_shared_capture_status
|
|
||||||
|
|
||||||
shared = get_shared_capture_status()
|
|
||||||
except Exception:
|
|
||||||
shared = {}
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'running': _state.audio_running,
|
|
||||||
'frequency': _state.audio_frequency,
|
|
||||||
'modulation': _state.audio_modulation,
|
|
||||||
'source': _state.audio_source,
|
|
||||||
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
|
||||||
'device': scanner_config.get('device', 0),
|
|
||||||
'gain': scanner_config.get('gain', 0),
|
|
||||||
'squelch': scanner_config.get('squelch', 0),
|
|
||||||
'audio_process_alive': bool(_state.audio_process and _state.audio_process.poll() is None),
|
|
||||||
'shared_capture': shared,
|
|
||||||
'rtl_fm_stderr': _read_log(rtl_log_path),
|
|
||||||
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
|
||||||
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/audio/probe')
|
|
||||||
def audio_probe() -> Response:
|
|
||||||
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
|
||||||
if _state.audio_source == 'waterfall':
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
|
|
||||||
|
|
||||||
data = read_shared_monitor_audio_chunk(timeout=2.0)
|
|
||||||
if not data:
|
|
||||||
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
|
|
||||||
sample_path = '/tmp/audio_probe.bin'
|
|
||||||
with open(sample_path, 'wb') as handle:
|
|
||||||
handle.write(data)
|
|
||||||
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
||||||
|
|
||||||
if not _state.audio_process or not _state.audio_process.stdout:
|
|
||||||
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
|
||||||
|
|
||||||
sample_path = '/tmp/audio_probe.bin'
|
|
||||||
size = 0
|
|
||||||
try:
|
|
||||||
ready, _, _ = select.select([_state.audio_process.stdout], [], [], 2.0)
|
|
||||||
if not ready:
|
|
||||||
return jsonify({'status': 'error', 'message': 'no data available'}), 504
|
|
||||||
data = _state.audio_process.stdout.read(4096)
|
|
||||||
if not data:
|
|
||||||
return jsonify({'status': 'error', 'message': 'no data read'}), 504
|
|
||||||
with open(sample_path, 'wb') as handle:
|
|
||||||
handle.write(data)
|
|
||||||
size = len(data)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
||||||
|
|
||||||
return jsonify({'status': 'ok', 'bytes': size})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/audio/stream')
|
|
||||||
def stream_audio() -> Response:
|
|
||||||
"""Stream WAV audio."""
|
|
||||||
request_token_raw = request.args.get('request_token')
|
|
||||||
request_token = None
|
|
||||||
if request_token_raw is not None:
|
|
||||||
try:
|
|
||||||
request_token = int(request_token_raw)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
request_token = None
|
|
||||||
|
|
||||||
if request_token is not None and request_token < _state.audio_start_token:
|
|
||||||
return Response(b'', mimetype='audio/wav', status=204)
|
|
||||||
|
|
||||||
if _state.audio_source == 'waterfall':
|
|
||||||
for _ in range(40):
|
|
||||||
if _state.audio_running:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
if not _state.audio_running:
|
|
||||||
return Response(b'', mimetype='audio/wav', status=204)
|
|
||||||
|
|
||||||
def generate_shared():
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import (
|
|
||||||
get_shared_capture_status,
|
|
||||||
read_shared_monitor_audio_chunk,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Browser expects an immediate WAV header.
|
|
||||||
yield _wav_header(sample_rate=48000)
|
|
||||||
inactive_since: float | None = None
|
|
||||||
|
|
||||||
while _state.audio_running and _state.audio_source == 'waterfall':
|
|
||||||
if request_token is not None and request_token < _state.audio_start_token:
|
|
||||||
break
|
|
||||||
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
|
|
||||||
if chunk:
|
|
||||||
inactive_since = None
|
|
||||||
yield chunk
|
|
||||||
continue
|
|
||||||
shared = get_shared_capture_status()
|
|
||||||
if shared.get('running') and shared.get('monitor_enabled'):
|
|
||||||
inactive_since = None
|
|
||||||
continue
|
|
||||||
if inactive_since is None:
|
|
||||||
inactive_since = time.monotonic()
|
|
||||||
continue
|
|
||||||
if (time.monotonic() - inactive_since) < 4.0:
|
|
||||||
continue
|
|
||||||
if not shared.get('running') or not shared.get('monitor_enabled'):
|
|
||||||
_state.audio_running = False
|
|
||||||
_state.audio_source = 'process'
|
|
||||||
break
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
generate_shared(),
|
|
||||||
mimetype='audio/wav',
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'audio/wav',
|
|
||||||
'Cache-Control': 'no-cache, no-store',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
'Transfer-Encoding': 'chunked',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for audio process to be ready (up to 2 seconds).
|
|
||||||
for _ in range(40):
|
|
||||||
if _state.audio_running and _state.audio_process:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
if not _state.audio_running or not _state.audio_process:
|
|
||||||
return Response(b'', mimetype='audio/wav', status=204)
|
|
||||||
|
|
||||||
def generate():
|
|
||||||
# Capture local reference to avoid race condition with stop
|
|
||||||
proc = _state.audio_process
|
|
||||||
if not proc or not proc.stdout:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# 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() + 20.0
|
|
||||||
warned_wait = False
|
|
||||||
while _state.audio_running and proc.poll() is None:
|
|
||||||
if request_token is not None and request_token < _state.audio_start_token:
|
|
||||||
break
|
|
||||||
# Use select to avoid blocking forever
|
|
||||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
|
||||||
if ready:
|
|
||||||
chunk = proc.stdout.read(8192)
|
|
||||||
if chunk:
|
|
||||||
warned_wait = False
|
|
||||||
yield chunk
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Keep connection open while demodulator settles.
|
|
||||||
if time.time() > first_chunk_deadline:
|
|
||||||
if not warned_wait:
|
|
||||||
logger.warning("Audio stream still waiting for first chunk")
|
|
||||||
warned_wait = True
|
|
||||||
continue
|
|
||||||
# Timeout - check if process died
|
|
||||||
if proc.poll() is not None:
|
|
||||||
break
|
|
||||||
except GeneratorExit:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Audio stream error: {e}")
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
generate(),
|
|
||||||
mimetype='audio/wav',
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'audio/wav',
|
|
||||||
'Cache-Control': 'no-cache, no-store',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
'Transfer-Encoding': 'chunked',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,804 +0,0 @@
|
|||||||
"""Scanner routes and implementation for frequency scanning."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import math
|
|
||||||
import queue
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Response, jsonify, request
|
|
||||||
|
|
||||||
import routes.listening_post as _state
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
_rtl_fm_demod_mode,
|
|
||||||
_start_audio_stream,
|
|
||||||
_stop_audio_stream,
|
|
||||||
activity_log,
|
|
||||||
activity_log_lock,
|
|
||||||
add_activity_log,
|
|
||||||
app_module,
|
|
||||||
find_rtl_fm,
|
|
||||||
find_rtl_power,
|
|
||||||
find_rx_fm,
|
|
||||||
logger,
|
|
||||||
normalize_modulation,
|
|
||||||
process_event,
|
|
||||||
receiver_bp,
|
|
||||||
scanner_config,
|
|
||||||
scanner_lock,
|
|
||||||
scanner_queue,
|
|
||||||
sse_stream_fanout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# SCANNER IMPLEMENTATION
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
def scanner_loop():
|
|
||||||
"""Main scanner loop - scans frequencies looking for signals."""
|
|
||||||
logger.info("Scanner thread started")
|
|
||||||
add_activity_log('scanner_start', scanner_config['start_freq'],
|
|
||||||
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
|
||||||
|
|
||||||
rtl_fm_path = find_rtl_fm()
|
|
||||||
|
|
||||||
if not rtl_fm_path:
|
|
||||||
logger.error("rtl_fm not found")
|
|
||||||
add_activity_log('error', 0, 'rtl_fm not found')
|
|
||||||
_state.scanner_running = False
|
|
||||||
return
|
|
||||||
|
|
||||||
current_freq = scanner_config['start_freq']
|
|
||||||
last_signal_time = 0
|
|
||||||
signal_detected = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
while _state.scanner_running:
|
|
||||||
# Check if paused
|
|
||||||
if _state.scanner_paused:
|
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Read config values on each iteration (allows live updates)
|
|
||||||
step_mhz = scanner_config['step'] / 1000.0
|
|
||||||
squelch = scanner_config['squelch']
|
|
||||||
mod = scanner_config['modulation']
|
|
||||||
gain = scanner_config['gain']
|
|
||||||
device = scanner_config['device']
|
|
||||||
|
|
||||||
_state.scanner_current_freq = current_freq
|
|
||||||
|
|
||||||
# Notify clients of frequency change
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'freq_change',
|
|
||||||
'frequency': current_freq,
|
|
||||||
'scanning': not signal_detected,
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
|
|
||||||
# Start rtl_fm at this frequency
|
|
||||||
freq_hz = int(current_freq * 1e6)
|
|
||||||
|
|
||||||
# Sample rates
|
|
||||||
if mod == 'wfm':
|
|
||||||
sample_rate = 170000
|
|
||||||
resample_rate = 32000
|
|
||||||
elif mod in ['usb', 'lsb']:
|
|
||||||
sample_rate = 12000
|
|
||||||
resample_rate = 12000
|
|
||||||
else:
|
|
||||||
sample_rate = 24000
|
|
||||||
resample_rate = 24000
|
|
||||||
|
|
||||||
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
|
||||||
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):
|
|
||||||
rtl_cmd.append('-T')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start rtl_fm
|
|
||||||
rtl_proc = subprocess.Popen(
|
|
||||||
rtl_cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
|
|
||||||
# Read audio data for analysis
|
|
||||||
audio_data = b''
|
|
||||||
|
|
||||||
# Read audio samples for a short period
|
|
||||||
sample_duration = 0.25 # 250ms - balance between speed and detection
|
|
||||||
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
|
|
||||||
|
|
||||||
while len(audio_data) < bytes_needed and _state.scanner_running:
|
|
||||||
chunk = rtl_proc.stdout.read(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
audio_data += chunk
|
|
||||||
|
|
||||||
# Clean up rtl_fm
|
|
||||||
rtl_proc.terminate()
|
|
||||||
try:
|
|
||||||
rtl_proc.wait(timeout=1)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
rtl_proc.kill()
|
|
||||||
|
|
||||||
# Analyze audio level
|
|
||||||
audio_detected = False
|
|
||||||
rms = 0
|
|
||||||
threshold = 500
|
|
||||||
if len(audio_data) > 100:
|
|
||||||
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
|
|
||||||
# Calculate RMS level (root mean square)
|
|
||||||
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
|
|
||||||
|
|
||||||
# Threshold based on squelch setting
|
|
||||||
# Lower squelch = more sensitive (lower threshold)
|
|
||||||
# squelch 0 = very sensitive, squelch 100 = only strong signals
|
|
||||||
if mod == 'wfm':
|
|
||||||
# WFM: threshold 500-10000 based on squelch
|
|
||||||
threshold = 500 + (squelch * 95)
|
|
||||||
min_threshold = 1500
|
|
||||||
else:
|
|
||||||
# AM/NFM: threshold 300-6500 based on squelch
|
|
||||||
threshold = 300 + (squelch * 62)
|
|
||||||
min_threshold = 900
|
|
||||||
|
|
||||||
effective_threshold = max(threshold, min_threshold)
|
|
||||||
audio_detected = rms > effective_threshold
|
|
||||||
|
|
||||||
# Send level info to clients
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'scan_update',
|
|
||||||
'frequency': current_freq,
|
|
||||||
'level': int(rms),
|
|
||||||
'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
|
|
||||||
'detected': audio_detected,
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
|
|
||||||
if audio_detected and _state.scanner_running:
|
|
||||||
if not signal_detected:
|
|
||||||
# New signal found!
|
|
||||||
signal_detected = True
|
|
||||||
last_signal_time = time.time()
|
|
||||||
add_activity_log('signal_found', current_freq,
|
|
||||||
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
|
|
||||||
logger.info(f"Signal found at {current_freq} MHz")
|
|
||||||
|
|
||||||
# Start audio streaming for user
|
|
||||||
_start_audio_stream(current_freq, mod)
|
|
||||||
|
|
||||||
try:
|
|
||||||
snr_db = round(10 * math.log10(rms / effective_threshold), 1) if rms > 0 and effective_threshold > 0 else 0.0
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_found',
|
|
||||||
'frequency': current_freq,
|
|
||||||
'modulation': mod,
|
|
||||||
'audio_streaming': True,
|
|
||||||
'level': int(rms),
|
|
||||||
'threshold': int(effective_threshold),
|
|
||||||
'snr': snr_db,
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check for skip signal
|
|
||||||
if _state.scanner_skip_signal:
|
|
||||||
_state.scanner_skip_signal = False
|
|
||||||
signal_detected = False
|
|
||||||
_stop_audio_stream()
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_skipped',
|
|
||||||
'frequency': current_freq
|
|
||||||
})
|
|
||||||
# Move to next frequency (step is in kHz, convert to MHz)
|
|
||||||
current_freq += step_mhz
|
|
||||||
if current_freq > scanner_config['end_freq']:
|
|
||||||
current_freq = scanner_config['start_freq']
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Stay on this frequency (dwell) but check periodically
|
|
||||||
dwell_start = time.time()
|
|
||||||
while (time.time() - dwell_start) < scanner_config['dwell_time'] and _state.scanner_running:
|
|
||||||
if _state.scanner_skip_signal:
|
|
||||||
break
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
last_signal_time = time.time()
|
|
||||||
|
|
||||||
# After dwell, move on to keep scanning
|
|
||||||
if _state.scanner_running and not _state.scanner_skip_signal:
|
|
||||||
signal_detected = False
|
|
||||||
_stop_audio_stream()
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_lost',
|
|
||||||
'frequency': current_freq,
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
|
|
||||||
current_freq += step_mhz
|
|
||||||
if current_freq > scanner_config['end_freq']:
|
|
||||||
current_freq = scanner_config['start_freq']
|
|
||||||
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
|
||||||
time.sleep(scanner_config['scan_delay'])
|
|
||||||
|
|
||||||
else:
|
|
||||||
# No signal at this frequency
|
|
||||||
if signal_detected:
|
|
||||||
# Signal lost
|
|
||||||
duration = time.time() - last_signal_time + scanner_config['dwell_time']
|
|
||||||
add_activity_log('signal_lost', current_freq,
|
|
||||||
f'Signal lost after {duration:.1f}s')
|
|
||||||
signal_detected = False
|
|
||||||
|
|
||||||
# Stop audio
|
|
||||||
_stop_audio_stream()
|
|
||||||
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_lost',
|
|
||||||
'frequency': current_freq
|
|
||||||
})
|
|
||||||
|
|
||||||
# Move to next frequency (step is in kHz, convert to MHz)
|
|
||||||
current_freq += step_mhz
|
|
||||||
if current_freq > scanner_config['end_freq']:
|
|
||||||
current_freq = scanner_config['start_freq']
|
|
||||||
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
|
||||||
|
|
||||||
time.sleep(scanner_config['scan_delay'])
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Scanner error at {current_freq} MHz: {e}")
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Scanner loop error: {e}")
|
|
||||||
finally:
|
|
||||||
_state.scanner_running = False
|
|
||||||
_stop_audio_stream()
|
|
||||||
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
|
|
||||||
logger.info("Scanner thread stopped")
|
|
||||||
|
|
||||||
|
|
||||||
def scanner_loop_power():
|
|
||||||
"""Power sweep scanner using rtl_power to detect peaks."""
|
|
||||||
logger.info("Power sweep scanner thread started")
|
|
||||||
add_activity_log('scanner_start', scanner_config['start_freq'],
|
|
||||||
f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
|
||||||
|
|
||||||
rtl_power_path = find_rtl_power()
|
|
||||||
if not rtl_power_path:
|
|
||||||
logger.error("rtl_power not found")
|
|
||||||
add_activity_log('error', 0, 'rtl_power not found')
|
|
||||||
_state.scanner_running = False
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
while _state.scanner_running:
|
|
||||||
if _state.scanner_paused:
|
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
start_mhz = scanner_config['start_freq']
|
|
||||||
end_mhz = scanner_config['end_freq']
|
|
||||||
step_khz = scanner_config['step']
|
|
||||||
gain = scanner_config['gain']
|
|
||||||
device = scanner_config['device']
|
|
||||||
scanner_config['squelch']
|
|
||||||
mod = scanner_config['modulation']
|
|
||||||
|
|
||||||
# Configure sweep
|
|
||||||
bin_hz = max(1000, int(step_khz * 1000))
|
|
||||||
start_hz = int(start_mhz * 1e6)
|
|
||||||
end_hz = int(end_mhz * 1e6)
|
|
||||||
# Integration time per sweep (seconds)
|
|
||||||
integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
rtl_power_path,
|
|
||||||
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
|
||||||
'-i', f'{integration}',
|
|
||||||
'-1',
|
|
||||||
'-g', str(gain),
|
|
||||||
'-d', str(device),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
||||||
_state.scanner_power_process = proc
|
|
||||||
stdout, _ = proc.communicate(timeout=15)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
stdout = b''
|
|
||||||
finally:
|
|
||||||
_state.scanner_power_process = None
|
|
||||||
|
|
||||||
if not _state.scanner_running:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not stdout:
|
|
||||||
add_activity_log('error', start_mhz, 'Power sweep produced no data')
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'scan_update',
|
|
||||||
'frequency': end_mhz,
|
|
||||||
'level': 0,
|
|
||||||
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
|
||||||
'detected': False,
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
time.sleep(0.2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
lines = stdout.decode(errors='ignore').splitlines()
|
|
||||||
segments = []
|
|
||||||
for line in lines:
|
|
||||||
if not line or line.startswith('#'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
parts = [p.strip() for p in line.split(',')]
|
|
||||||
# Find start_hz token
|
|
||||||
start_idx = None
|
|
||||||
for i, tok in enumerate(parts):
|
|
||||||
try:
|
|
||||||
val = float(tok)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if val > 1e5:
|
|
||||||
start_idx = i
|
|
||||||
break
|
|
||||||
if start_idx is None or len(parts) < start_idx + 6:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
sweep_start = float(parts[start_idx])
|
|
||||||
sweep_end = float(parts[start_idx + 1])
|
|
||||||
sweep_bin = float(parts[start_idx + 2])
|
|
||||||
raw_values = []
|
|
||||||
for v in parts[start_idx + 3:]:
|
|
||||||
try:
|
|
||||||
raw_values.append(float(v))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
# rtl_power may include a samples field before the power list
|
|
||||||
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
|
||||||
raw_values = raw_values[1:]
|
|
||||||
bin_values = raw_values
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not bin_values:
|
|
||||||
continue
|
|
||||||
|
|
||||||
segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
|
|
||||||
|
|
||||||
if not segments:
|
|
||||||
add_activity_log('error', start_mhz, 'Power sweep bins missing')
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'scan_update',
|
|
||||||
'frequency': end_mhz,
|
|
||||||
'level': 0,
|
|
||||||
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
|
||||||
'detected': False,
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
time.sleep(0.2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Process segments in ascending frequency order to avoid backtracking in UI
|
|
||||||
segments.sort(key=lambda s: s[0])
|
|
||||||
total_bins = sum(len(seg[3]) for seg in segments)
|
|
||||||
if total_bins <= 0:
|
|
||||||
time.sleep(0.2)
|
|
||||||
continue
|
|
||||||
segment_offset = 0
|
|
||||||
|
|
||||||
for sweep_start, sweep_end, sweep_bin, bin_values in segments:
|
|
||||||
# Noise floor (median)
|
|
||||||
sorted_vals = sorted(bin_values)
|
|
||||||
mid = len(sorted_vals) // 2
|
|
||||||
noise_floor = sorted_vals[mid]
|
|
||||||
|
|
||||||
# SNR threshold (dB)
|
|
||||||
snr_threshold = float(scanner_config.get('snr_threshold', 12))
|
|
||||||
|
|
||||||
# Emit progress updates (throttled)
|
|
||||||
emit_stride = max(1, len(bin_values) // 60)
|
|
||||||
for idx, val in enumerate(bin_values):
|
|
||||||
if idx % emit_stride != 0 and idx != len(bin_values) - 1:
|
|
||||||
continue
|
|
||||||
freq_hz = sweep_start + sweep_bin * idx
|
|
||||||
_state.scanner_current_freq = freq_hz / 1e6
|
|
||||||
snr = val - noise_floor
|
|
||||||
level = int(max(0, snr) * 100)
|
|
||||||
threshold = int(snr_threshold * 100)
|
|
||||||
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'scan_update',
|
|
||||||
'frequency': _state.scanner_current_freq,
|
|
||||||
'level': level,
|
|
||||||
'threshold': threshold,
|
|
||||||
'detected': snr >= snr_threshold,
|
|
||||||
'progress': progress,
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
segment_offset += len(bin_values)
|
|
||||||
|
|
||||||
# Detect peaks (clusters above threshold)
|
|
||||||
peaks = []
|
|
||||||
in_cluster = False
|
|
||||||
peak_idx = None
|
|
||||||
peak_val = None
|
|
||||||
for idx, val in enumerate(bin_values):
|
|
||||||
snr = val - noise_floor
|
|
||||||
if snr >= snr_threshold:
|
|
||||||
if not in_cluster:
|
|
||||||
in_cluster = True
|
|
||||||
peak_idx = idx
|
|
||||||
peak_val = val
|
|
||||||
else:
|
|
||||||
if val > peak_val:
|
|
||||||
peak_val = val
|
|
||||||
peak_idx = idx
|
|
||||||
else:
|
|
||||||
if in_cluster and peak_idx is not None:
|
|
||||||
peaks.append((peak_idx, peak_val))
|
|
||||||
in_cluster = False
|
|
||||||
peak_idx = None
|
|
||||||
peak_val = None
|
|
||||||
if in_cluster and peak_idx is not None:
|
|
||||||
peaks.append((peak_idx, peak_val))
|
|
||||||
|
|
||||||
for idx, val in peaks:
|
|
||||||
freq_hz = sweep_start + sweep_bin * (idx + 0.5)
|
|
||||||
freq_mhz = freq_hz / 1e6
|
|
||||||
snr = val - noise_floor
|
|
||||||
level = int(max(0, snr) * 100)
|
|
||||||
threshold = int(snr_threshold * 100)
|
|
||||||
add_activity_log('signal_found', freq_mhz,
|
|
||||||
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_found',
|
|
||||||
'frequency': freq_mhz,
|
|
||||||
'modulation': mod,
|
|
||||||
'audio_streaming': False,
|
|
||||||
'level': level,
|
|
||||||
'threshold': threshold,
|
|
||||||
'snr': round(snr, 1),
|
|
||||||
'range_start': scanner_config['start_freq'],
|
|
||||||
'range_end': scanner_config['end_freq']
|
|
||||||
})
|
|
||||||
|
|
||||||
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
|
|
||||||
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Power sweep scanner error: {e}")
|
|
||||||
finally:
|
|
||||||
_state.scanner_running = False
|
|
||||||
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
|
|
||||||
logger.info("Power sweep scanner thread stopped")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# SCANNER API ENDPOINTS
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/start', methods=['POST'])
|
|
||||||
def start_scanner() -> Response:
|
|
||||||
"""Start the frequency scanner."""
|
|
||||||
with scanner_lock:
|
|
||||||
if _state.scanner_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Scanner already running'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
# Clear stale queue entries so UI updates immediately
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
scanner_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
# Update scanner config
|
|
||||||
try:
|
|
||||||
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
|
|
||||||
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
|
||||||
scanner_config['step'] = float(data.get('step', 0.1))
|
|
||||||
scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm'))
|
|
||||||
scanner_config['squelch'] = int(data.get('squelch', 0))
|
|
||||||
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
|
||||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
|
||||||
scanner_config['device'] = int(data.get('device', 0))
|
|
||||||
scanner_config['gain'] = int(data.get('gain', 40))
|
|
||||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
|
||||||
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
|
||||||
scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
|
|
||||||
if data.get('snr_threshold') is not None:
|
|
||||||
scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid parameter: {e}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Validate
|
|
||||||
if scanner_config['start_freq'] >= scanner_config['end_freq']:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'start_freq must be less than end_freq'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Decide scan method
|
|
||||||
if not scanner_config['scan_method']:
|
|
||||||
scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
|
|
||||||
|
|
||||||
sdr_type = scanner_config['sdr_type']
|
|
||||||
|
|
||||||
# Power scan only supports RTL-SDR for now
|
|
||||||
if scanner_config['scan_method'] == 'power' and (sdr_type != 'rtlsdr' or not find_rtl_power()):
|
|
||||||
scanner_config['scan_method'] = 'classic'
|
|
||||||
|
|
||||||
# Check tools based on chosen method
|
|
||||||
if scanner_config['scan_method'] == 'power':
|
|
||||||
if not find_rtl_power():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
|
||||||
}), 503
|
|
||||||
# Release listening device if active
|
|
||||||
if _state.receiver_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
|
||||||
_state.receiver_active_device = None
|
|
||||||
_state.receiver_active_sdr_type = 'rtlsdr'
|
|
||||||
# Claim device for scanner
|
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
|
||||||
if error:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
_state.scanner_active_device = scanner_config['device']
|
|
||||||
_state.scanner_active_sdr_type = scanner_config['sdr_type']
|
|
||||||
_state.scanner_running = True
|
|
||||||
_state.scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
|
||||||
_state.scanner_thread.start()
|
|
||||||
else:
|
|
||||||
if sdr_type == 'rtlsdr':
|
|
||||||
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 utilities for {sdr_type}.'
|
|
||||||
}), 503
|
|
||||||
if _state.receiver_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
|
||||||
_state.receiver_active_device = None
|
|
||||||
_state.receiver_active_sdr_type = 'rtlsdr'
|
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
|
||||||
if error:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
|
||||||
}), 409
|
|
||||||
_state.scanner_active_device = scanner_config['device']
|
|
||||||
_state.scanner_active_sdr_type = scanner_config['sdr_type']
|
|
||||||
|
|
||||||
_state.scanner_running = True
|
|
||||||
_state.scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
|
||||||
_state.scanner_thread.start()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'config': scanner_config
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
|
||||||
def stop_scanner() -> Response:
|
|
||||||
"""Stop the frequency scanner."""
|
|
||||||
_state.scanner_running = False
|
|
||||||
_stop_audio_stream()
|
|
||||||
if _state.scanner_power_process and _state.scanner_power_process.poll() is None:
|
|
||||||
try:
|
|
||||||
_state.scanner_power_process.terminate()
|
|
||||||
_state.scanner_power_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
_state.scanner_power_process.kill()
|
|
||||||
_state.scanner_power_process = None
|
|
||||||
if _state.scanner_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
|
|
||||||
_state.scanner_active_device = None
|
|
||||||
_state.scanner_active_sdr_type = 'rtlsdr'
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/pause', methods=['POST'])
|
|
||||||
def pause_scanner() -> Response:
|
|
||||||
"""Pause/resume the scanner."""
|
|
||||||
_state.scanner_paused = not _state.scanner_paused
|
|
||||||
|
|
||||||
if _state.scanner_paused:
|
|
||||||
add_activity_log('scanner_pause', _state.scanner_current_freq, 'Scanner paused')
|
|
||||||
else:
|
|
||||||
add_activity_log('scanner_resume', _state.scanner_current_freq, 'Scanner resumed')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'paused' if _state.scanner_paused else 'resumed',
|
|
||||||
'paused': _state.scanner_paused
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/skip', methods=['POST'])
|
|
||||||
def skip_signal() -> Response:
|
|
||||||
"""Skip current signal and continue scanning."""
|
|
||||||
if not _state.scanner_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Scanner not running'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
_state.scanner_skip_signal = True
|
|
||||||
add_activity_log('signal_skip', _state.scanner_current_freq, f'Skipped signal at {_state.scanner_current_freq:.3f} MHz')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'skipped',
|
|
||||||
'frequency': _state.scanner_current_freq
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/config', methods=['POST'])
|
|
||||||
def update_scanner_config() -> Response:
|
|
||||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
updated = []
|
|
||||||
|
|
||||||
if 'step' in data:
|
|
||||||
scanner_config['step'] = float(data['step'])
|
|
||||||
updated.append(f"step={data['step']}kHz")
|
|
||||||
|
|
||||||
if 'squelch' in data:
|
|
||||||
scanner_config['squelch'] = int(data['squelch'])
|
|
||||||
updated.append(f"squelch={data['squelch']}")
|
|
||||||
|
|
||||||
if 'gain' in data:
|
|
||||||
scanner_config['gain'] = int(data['gain'])
|
|
||||||
updated.append(f"gain={data['gain']}")
|
|
||||||
|
|
||||||
if 'dwell_time' in data:
|
|
||||||
scanner_config['dwell_time'] = int(data['dwell_time'])
|
|
||||||
updated.append(f"dwell={data['dwell_time']}s")
|
|
||||||
|
|
||||||
if 'modulation' in data:
|
|
||||||
try:
|
|
||||||
scanner_config['modulation'] = normalize_modulation(data['modulation'])
|
|
||||||
updated.append(f"mod={data['modulation']}")
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
logger.info(f"Scanner config updated: {', '.join(updated)}")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'updated',
|
|
||||||
'config': scanner_config
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/status')
|
|
||||||
def scanner_status() -> Response:
|
|
||||||
"""Get scanner status."""
|
|
||||||
return jsonify({
|
|
||||||
'running': _state.scanner_running,
|
|
||||||
'paused': _state.scanner_paused,
|
|
||||||
'current_freq': _state.scanner_current_freq,
|
|
||||||
'config': scanner_config,
|
|
||||||
'audio_streaming': _state.audio_running,
|
|
||||||
'audio_frequency': _state.audio_frequency
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/stream')
|
|
||||||
def stream_scanner_events() -> Response:
|
|
||||||
"""SSE stream for scanner events."""
|
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
|
||||||
process_event('receiver_scanner', msg, msg.get('type'))
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=scanner_queue,
|
|
||||||
channel_key='receiver_scanner',
|
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
|
||||||
on_message=_on_msg,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
)
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/log')
|
|
||||||
def get_activity_log() -> Response:
|
|
||||||
"""Get activity log."""
|
|
||||||
limit = request.args.get('limit', 100, type=int)
|
|
||||||
with activity_log_lock:
|
|
||||||
return jsonify({
|
|
||||||
'log': activity_log[:limit],
|
|
||||||
'total': len(activity_log)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/scanner/log/clear', methods=['POST'])
|
|
||||||
def clear_activity_log() -> Response:
|
|
||||||
"""Clear activity log."""
|
|
||||||
with activity_log_lock:
|
|
||||||
activity_log.clear()
|
|
||||||
return jsonify({'status': 'cleared'})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/presets')
|
|
||||||
def get_presets() -> Response:
|
|
||||||
"""Get scanner presets."""
|
|
||||||
presets = [
|
|
||||||
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
|
|
||||||
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
|
|
||||||
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
|
|
||||||
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
|
|
||||||
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
]
|
|
||||||
return jsonify({'presets': presets})
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Tool check and signal identification routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flask import Response, jsonify, request
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
find_ffmpeg,
|
|
||||||
find_rtl_fm,
|
|
||||||
find_rtl_power,
|
|
||||||
find_rx_fm,
|
|
||||||
logger,
|
|
||||||
receiver_bp,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# TOOL CHECK ENDPOINT
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@receiver_bp.route('/tools')
|
|
||||||
def check_tools() -> Response:
|
|
||||||
"""Check for required tools."""
|
|
||||||
rtl_fm = find_rtl_fm()
|
|
||||||
rtl_power = find_rtl_power()
|
|
||||||
rx_fm = find_rx_fm()
|
|
||||||
ffmpeg = find_ffmpeg()
|
|
||||||
|
|
||||||
# Determine which SDR types are supported
|
|
||||||
supported_sdr_types = []
|
|
||||||
if rtl_fm:
|
|
||||||
supported_sdr_types.append('rtlsdr')
|
|
||||||
if rx_fm:
|
|
||||||
# rx_fm from SoapySDR supports these types
|
|
||||||
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'rtl_fm': rtl_fm is not None,
|
|
||||||
'rtl_power': rtl_power is not None,
|
|
||||||
'rx_fm': rx_fm is not None,
|
|
||||||
'ffmpeg': ffmpeg is not None,
|
|
||||||
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
|
||||||
'supported_sdr_types': supported_sdr_types
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# SIGNAL IDENTIFICATION ENDPOINT
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@receiver_bp.route('/signal/guess', methods=['POST'])
|
|
||||||
def guess_signal() -> Response:
|
|
||||||
"""Identify a signal based on frequency, modulation, and other parameters."""
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
freq_mhz = data.get('frequency_mhz')
|
|
||||||
if freq_mhz is None:
|
|
||||||
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
freq_mhz = float(freq_mhz)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
|
||||||
|
|
||||||
if freq_mhz <= 0:
|
|
||||||
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
|
||||||
|
|
||||||
frequency_hz = int(freq_mhz * 1e6)
|
|
||||||
|
|
||||||
modulation = data.get('modulation')
|
|
||||||
bandwidth_hz = data.get('bandwidth_hz')
|
|
||||||
if bandwidth_hz is not None:
|
|
||||||
try:
|
|
||||||
bandwidth_hz = int(bandwidth_hz)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
bandwidth_hz = None
|
|
||||||
|
|
||||||
region = data.get('region', 'UK/EU')
|
|
||||||
|
|
||||||
try:
|
|
||||||
from utils.signal_guess import guess_signal_type_dict
|
|
||||||
result = guess_signal_type_dict(
|
|
||||||
frequency_hz=frequency_hz,
|
|
||||||
modulation=modulation,
|
|
||||||
bandwidth_hz=bandwidth_hz,
|
|
||||||
region=region,
|
|
||||||
)
|
|
||||||
return jsonify({'status': 'ok', **result})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Signal guess error: {e}")
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
"""Waterfall / spectrogram routes and implementation."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import math
|
|
||||||
import queue
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Response, jsonify, request
|
|
||||||
|
|
||||||
import routes.listening_post as _state
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
SDRFactory,
|
|
||||||
SDRType,
|
|
||||||
_stop_waterfall_internal,
|
|
||||||
app_module,
|
|
||||||
find_rtl_power,
|
|
||||||
logger,
|
|
||||||
process_event,
|
|
||||||
receiver_bp,
|
|
||||||
sse_stream_fanout,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# WATERFALL HELPER FUNCTIONS
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
|
|
||||||
"""Parse a single rtl_power CSV line into bins."""
|
|
||||||
if not line or line.startswith('#'):
|
|
||||||
return None, None, None, []
|
|
||||||
|
|
||||||
parts = [p.strip() for p in line.split(',')]
|
|
||||||
if len(parts) < 6:
|
|
||||||
return None, None, None, []
|
|
||||||
|
|
||||||
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
|
|
||||||
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
|
|
||||||
|
|
||||||
start_idx = None
|
|
||||||
for i, tok in enumerate(parts):
|
|
||||||
try:
|
|
||||||
val = float(tok)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if val > 1e5:
|
|
||||||
start_idx = i
|
|
||||||
break
|
|
||||||
if start_idx is None or len(parts) < start_idx + 4:
|
|
||||||
return timestamp, None, None, []
|
|
||||||
|
|
||||||
try:
|
|
||||||
seg_start = float(parts[start_idx])
|
|
||||||
seg_end = float(parts[start_idx + 1])
|
|
||||||
raw_values = []
|
|
||||||
for v in parts[start_idx + 3:]:
|
|
||||||
try:
|
|
||||||
raw_values.append(float(v))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
|
||||||
raw_values = raw_values[1:]
|
|
||||||
return timestamp, seg_start, seg_end, raw_values
|
|
||||||
except ValueError:
|
|
||||||
return timestamp, None, None, []
|
|
||||||
|
|
||||||
|
|
||||||
def _queue_waterfall_error(message: str) -> None:
|
|
||||||
"""Push an error message onto the waterfall SSE queue."""
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
_state.waterfall_queue.put_nowait({
|
|
||||||
'type': 'waterfall_error',
|
|
||||||
'message': message,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
|
||||||
"""Downsample bins to a target length using simple averaging."""
|
|
||||||
if target <= 0 or len(values) <= target:
|
|
||||||
return values
|
|
||||||
|
|
||||||
out: list[float] = []
|
|
||||||
step = len(values) / target
|
|
||||||
for i in range(target):
|
|
||||||
start = int(i * step)
|
|
||||||
end = int((i + 1) * step)
|
|
||||||
if end <= start:
|
|
||||||
end = min(start + 1, len(values))
|
|
||||||
chunk = values[start:end]
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
out.append(sum(chunk) / len(chunk))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# WATERFALL LOOP IMPLEMENTATIONS
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
def _waterfall_loop():
|
|
||||||
"""Continuous waterfall sweep loop emitting FFT data."""
|
|
||||||
sdr_type_str = _state.waterfall_config.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
|
||||||
_waterfall_loop_rtl_power()
|
|
||||||
else:
|
|
||||||
_waterfall_loop_iq(sdr_type)
|
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_loop_iq(sdr_type: SDRType):
|
|
||||||
"""Waterfall loop using rx_sdr IQ capture + FFT for HackRF/SoapySDR devices."""
|
|
||||||
start_freq = _state.waterfall_config['start_freq']
|
|
||||||
end_freq = _state.waterfall_config['end_freq']
|
|
||||||
gain = _state.waterfall_config['gain']
|
|
||||||
device = _state.waterfall_config['device']
|
|
||||||
interval = float(_state.waterfall_config.get('interval', 0.4))
|
|
||||||
|
|
||||||
# Use center frequency and sample rate to cover the requested span
|
|
||||||
center_mhz = (start_freq + end_freq) / 2.0
|
|
||||||
span_hz = (end_freq - start_freq) * 1e6
|
|
||||||
# Pick a sample rate that covers the span (minimum 2 MHz for HackRF)
|
|
||||||
sample_rate = max(2000000, int(span_hz))
|
|
||||||
# Cap to sensible maximum
|
|
||||||
sample_rate = min(sample_rate, 20000000)
|
|
||||||
|
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
|
|
||||||
cmd = builder.build_iq_capture_command(
|
|
||||||
device=sdr_device,
|
|
||||||
frequency_mhz=center_mhz,
|
|
||||||
sample_rate=sample_rate,
|
|
||||||
gain=float(gain),
|
|
||||||
)
|
|
||||||
|
|
||||||
fft_size = min(int(_state.waterfall_config.get('max_bins') or 1024), 4096)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_state.waterfall_process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Detect immediate startup failures
|
|
||||||
time.sleep(0.35)
|
|
||||||
if _state.waterfall_process.poll() is not None:
|
|
||||||
stderr_text = ''
|
|
||||||
try:
|
|
||||||
if _state.waterfall_process.stderr:
|
|
||||||
stderr_text = _state.waterfall_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
|
||||||
except Exception:
|
|
||||||
stderr_text = ''
|
|
||||||
msg = stderr_text or f'IQ capture exited early (code {_state.waterfall_process.returncode})'
|
|
||||||
logger.error(f"Waterfall startup failed: {msg}")
|
|
||||||
_queue_waterfall_error(msg)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not _state.waterfall_process.stdout:
|
|
||||||
_queue_waterfall_error('IQ capture stdout unavailable')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Read IQ samples and compute FFT
|
|
||||||
# CU8 format: interleaved unsigned 8-bit I/Q pairs
|
|
||||||
bytes_per_sample = 2 # 1 byte I + 1 byte Q
|
|
||||||
chunk_bytes = fft_size * bytes_per_sample
|
|
||||||
received_any = False
|
|
||||||
|
|
||||||
while _state.waterfall_running:
|
|
||||||
raw = _state.waterfall_process.stdout.read(chunk_bytes)
|
|
||||||
if not raw or len(raw) < chunk_bytes:
|
|
||||||
if _state.waterfall_process.poll() is not None:
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
|
|
||||||
received_any = True
|
|
||||||
|
|
||||||
# Convert CU8 to complex float: center at 127.5
|
|
||||||
iq = struct.unpack(f'{fft_size * 2}B', raw)
|
|
||||||
# Compute power spectrum via FFT
|
|
||||||
real_parts = [(iq[i * 2] - 127.5) / 127.5 for i in range(fft_size)]
|
|
||||||
imag_parts = [(iq[i * 2 + 1] - 127.5) / 127.5 for i in range(fft_size)]
|
|
||||||
|
|
||||||
bins: list[float] = []
|
|
||||||
try:
|
|
||||||
# Try numpy if available for efficient FFT
|
|
||||||
import numpy as np
|
|
||||||
samples = np.array(real_parts, dtype=np.float32) + 1j * np.array(imag_parts, dtype=np.float32)
|
|
||||||
# Apply Hann window
|
|
||||||
window = np.hanning(fft_size)
|
|
||||||
samples *= window
|
|
||||||
spectrum = np.fft.fftshift(np.fft.fft(samples))
|
|
||||||
power_db = 10.0 * np.log10(np.abs(spectrum) ** 2 + 1e-10)
|
|
||||||
bins = power_db.tolist()
|
|
||||||
except ImportError:
|
|
||||||
# Fallback: compute magnitude without full FFT
|
|
||||||
# Just report raw magnitudes per sample as approximate power
|
|
||||||
for i in range(fft_size):
|
|
||||||
mag = math.sqrt(real_parts[i] ** 2 + imag_parts[i] ** 2)
|
|
||||||
power = 10.0 * math.log10(mag ** 2 + 1e-10)
|
|
||||||
bins.append(power)
|
|
||||||
|
|
||||||
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
|
|
||||||
if max_bins > 0 and len(bins) > max_bins:
|
|
||||||
bins = _downsample_bins(bins, max_bins)
|
|
||||||
|
|
||||||
msg = {
|
|
||||||
'type': 'waterfall_sweep',
|
|
||||||
'start_freq': start_freq,
|
|
||||||
'end_freq': end_freq,
|
|
||||||
'bins': bins,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
_state.waterfall_queue.put_nowait(msg)
|
|
||||||
except queue.Full:
|
|
||||||
with contextlib.suppress(queue.Empty):
|
|
||||||
_state.waterfall_queue.get_nowait()
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
_state.waterfall_queue.put_nowait(msg)
|
|
||||||
|
|
||||||
# Throttle to respect interval
|
|
||||||
time.sleep(interval)
|
|
||||||
|
|
||||||
if _state.waterfall_running and not received_any:
|
|
||||||
_queue_waterfall_error(f'No IQ data received from {sdr_type.value}')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Waterfall IQ loop error: {e}")
|
|
||||||
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
|
||||||
finally:
|
|
||||||
_state.waterfall_running = False
|
|
||||||
if _state.waterfall_process and _state.waterfall_process.poll() is None:
|
|
||||||
try:
|
|
||||||
_state.waterfall_process.terminate()
|
|
||||||
_state.waterfall_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
_state.waterfall_process.kill()
|
|
||||||
_state.waterfall_process = None
|
|
||||||
logger.info("Waterfall IQ loop stopped")
|
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_loop_rtl_power():
|
|
||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
|
||||||
rtl_power_path = find_rtl_power()
|
|
||||||
if not rtl_power_path:
|
|
||||||
logger.error("rtl_power not found for waterfall")
|
|
||||||
_queue_waterfall_error('rtl_power not found')
|
|
||||||
_state.waterfall_running = False
|
|
||||||
return
|
|
||||||
|
|
||||||
start_hz = int(_state.waterfall_config['start_freq'] * 1e6)
|
|
||||||
end_hz = int(_state.waterfall_config['end_freq'] * 1e6)
|
|
||||||
bin_hz = int(_state.waterfall_config['bin_size'])
|
|
||||||
gain = _state.waterfall_config['gain']
|
|
||||||
device = _state.waterfall_config['device']
|
|
||||||
interval = float(_state.waterfall_config.get('interval', 0.4))
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
rtl_power_path,
|
|
||||||
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
|
||||||
'-i', str(interval),
|
|
||||||
'-g', str(gain),
|
|
||||||
'-d', str(device),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
_state.waterfall_process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
bufsize=1,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Detect immediate startup failures (e.g. device busy / no device).
|
|
||||||
time.sleep(0.35)
|
|
||||||
if _state.waterfall_process.poll() is not None:
|
|
||||||
stderr_text = ''
|
|
||||||
try:
|
|
||||||
if _state.waterfall_process.stderr:
|
|
||||||
stderr_text = _state.waterfall_process.stderr.read().strip()
|
|
||||||
except Exception:
|
|
||||||
stderr_text = ''
|
|
||||||
msg = stderr_text or f'rtl_power exited early (code {_state.waterfall_process.returncode})'
|
|
||||||
logger.error(f"Waterfall startup failed: {msg}")
|
|
||||||
_queue_waterfall_error(msg)
|
|
||||||
return
|
|
||||||
|
|
||||||
current_ts = None
|
|
||||||
all_bins: list[float] = []
|
|
||||||
sweep_start_hz = start_hz
|
|
||||||
sweep_end_hz = end_hz
|
|
||||||
received_any = False
|
|
||||||
|
|
||||||
if not _state.waterfall_process.stdout:
|
|
||||||
_queue_waterfall_error('rtl_power stdout unavailable')
|
|
||||||
return
|
|
||||||
|
|
||||||
for line in _state.waterfall_process.stdout:
|
|
||||||
if not _state.waterfall_running:
|
|
||||||
break
|
|
||||||
|
|
||||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
|
||||||
if ts is None or not bins:
|
|
||||||
continue
|
|
||||||
received_any = True
|
|
||||||
|
|
||||||
if current_ts is None:
|
|
||||||
current_ts = ts
|
|
||||||
|
|
||||||
if ts != current_ts and all_bins:
|
|
||||||
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
|
|
||||||
bins_to_send = all_bins
|
|
||||||
if max_bins > 0 and len(bins_to_send) > max_bins:
|
|
||||||
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
|
||||||
msg = {
|
|
||||||
'type': 'waterfall_sweep',
|
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
|
||||||
'bins': bins_to_send,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
_state.waterfall_queue.put_nowait(msg)
|
|
||||||
except queue.Full:
|
|
||||||
with contextlib.suppress(queue.Empty):
|
|
||||||
_state.waterfall_queue.get_nowait()
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
_state.waterfall_queue.put_nowait(msg)
|
|
||||||
|
|
||||||
all_bins = []
|
|
||||||
sweep_start_hz = start_hz
|
|
||||||
sweep_end_hz = end_hz
|
|
||||||
current_ts = ts
|
|
||||||
|
|
||||||
all_bins.extend(bins)
|
|
||||||
if seg_start is not None:
|
|
||||||
sweep_start_hz = min(sweep_start_hz, seg_start)
|
|
||||||
if seg_end is not None:
|
|
||||||
sweep_end_hz = max(sweep_end_hz, seg_end)
|
|
||||||
|
|
||||||
# Flush any remaining bins
|
|
||||||
if all_bins and _state.waterfall_running:
|
|
||||||
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
|
|
||||||
bins_to_send = all_bins
|
|
||||||
if max_bins > 0 and len(bins_to_send) > max_bins:
|
|
||||||
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
|
||||||
msg = {
|
|
||||||
'type': 'waterfall_sweep',
|
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
|
||||||
'bins': bins_to_send,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
_state.waterfall_queue.put_nowait(msg)
|
|
||||||
|
|
||||||
if _state.waterfall_running and not received_any:
|
|
||||||
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
|
||||||
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
|
||||||
finally:
|
|
||||||
_state.waterfall_running = False
|
|
||||||
if _state.waterfall_process and _state.waterfall_process.poll() is None:
|
|
||||||
try:
|
|
||||||
_state.waterfall_process.terminate()
|
|
||||||
_state.waterfall_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
_state.waterfall_process.kill()
|
|
||||||
_state.waterfall_process = None
|
|
||||||
logger.info("Waterfall loop stopped")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# WATERFALL API ENDPOINTS
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
|
||||||
def start_waterfall() -> Response:
|
|
||||||
"""Start the waterfall/spectrogram display."""
|
|
||||||
with _state.waterfall_lock:
|
|
||||||
if _state.waterfall_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'already_running': True,
|
|
||||||
'message': 'Waterfall already running',
|
|
||||||
'config': _state.waterfall_config,
|
|
||||||
})
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
# Determine SDR type
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
sdr_type_str = sdr_type.value
|
|
||||||
|
|
||||||
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
|
|
||||||
if sdr_type == SDRType.RTL_SDR and not find_rtl_power():
|
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
|
||||||
|
|
||||||
try:
|
|
||||||
_state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
|
|
||||||
_state.waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
|
|
||||||
_state.waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
|
||||||
_state.waterfall_config['gain'] = int(data.get('gain', 40))
|
|
||||||
_state.waterfall_config['device'] = int(data.get('device', 0))
|
|
||||||
_state.waterfall_config['sdr_type'] = sdr_type_str
|
|
||||||
if data.get('interval') is not None:
|
|
||||||
interval = float(data.get('interval', _state.waterfall_config['interval']))
|
|
||||||
if interval < 0.1 or interval > 5:
|
|
||||||
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
|
|
||||||
_state.waterfall_config['interval'] = interval
|
|
||||||
if data.get('max_bins') is not None:
|
|
||||||
max_bins = int(data.get('max_bins', _state.waterfall_config['max_bins']))
|
|
||||||
if max_bins < 64 or max_bins > 4096:
|
|
||||||
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
|
|
||||||
_state.waterfall_config['max_bins'] = max_bins
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
|
||||||
|
|
||||||
if _state.waterfall_config['start_freq'] >= _state.waterfall_config['end_freq']:
|
|
||||||
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
|
|
||||||
|
|
||||||
# Clear stale queue
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
_state.waterfall_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Claim SDR device
|
|
||||||
error = app_module.claim_sdr_device(_state.waterfall_config['device'], 'waterfall', sdr_type_str)
|
|
||||||
if error:
|
|
||||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
|
||||||
|
|
||||||
_state.waterfall_active_device = _state.waterfall_config['device']
|
|
||||||
_state.waterfall_active_sdr_type = sdr_type_str
|
|
||||||
_state.waterfall_running = True
|
|
||||||
_state.waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
|
||||||
_state.waterfall_thread.start()
|
|
||||||
|
|
||||||
return jsonify({'status': 'started', 'config': _state.waterfall_config})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/waterfall/stop', methods=['POST'])
|
|
||||||
def stop_waterfall() -> Response:
|
|
||||||
"""Stop the waterfall display."""
|
|
||||||
_stop_waterfall_internal()
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/waterfall/stream')
|
|
||||||
def stream_waterfall() -> Response:
|
|
||||||
"""SSE stream for waterfall data."""
|
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
|
||||||
process_event('waterfall', msg, msg.get('type'))
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=_state.waterfall_queue,
|
|
||||||
channel_key='receiver_waterfall',
|
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
|
||||||
on_message=_on_msg,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
)
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
@@ -11,19 +11,20 @@ Supports multiple connection types:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
from utils.meshtastic import (
|
from utils.meshtastic import (
|
||||||
MeshtasticMessage,
|
|
||||||
get_meshtastic_client,
|
get_meshtastic_client,
|
||||||
is_meshtastic_available,
|
|
||||||
start_meshtastic,
|
start_meshtastic,
|
||||||
stop_meshtastic,
|
stop_meshtastic,
|
||||||
|
is_meshtastic_available,
|
||||||
|
MeshtasticMessage,
|
||||||
)
|
)
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
|
|
||||||
logger = get_logger('intercept.meshtastic')
|
logger = get_logger('intercept.meshtastic')
|
||||||
|
|
||||||
@@ -468,15 +469,22 @@ def stream_messages():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
response = Response(
|
def generate() -> Generator[str, None, None]:
|
||||||
sse_stream_fanout(
|
last_keepalive = time.time()
|
||||||
source_queue=_mesh_queue,
|
keepalive_interval = 30.0
|
||||||
channel_key='meshtastic',
|
|
||||||
timeout=1.0,
|
while True:
|
||||||
keepalive_interval=30.0,
|
try:
|
||||||
),
|
msg = _mesh_queue.get(timeout=1)
|
||||||
mimetype='text/event-stream',
|
last_keepalive = time.time()
|
||||||
)
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
@@ -1043,19 +1051,3 @@ def request_store_forward():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': error or 'Failed to request S&F history'
|
'message': error or 'Failed to request S&F history'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@meshtastic_bp.route('/topology')
|
|
||||||
def mesh_topology():
|
|
||||||
"""Return mesh network topology graph."""
|
|
||||||
if not is_meshtastic_available():
|
|
||||||
return api_error('Meshtastic SDK not installed', 400)
|
|
||||||
|
|
||||||
client = get_meshtastic_client()
|
|
||||||
if not client or not client.is_running:
|
|
||||||
return api_error('Not connected', 400)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'topology': client.get_topology(),
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,599 +0,0 @@
|
|||||||
"""WebSocket-based meteor scatter monitoring with waterfall display and ping detection.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
- WebSocket at /ws/meteor for binary waterfall frames (reuses waterfall_fft pipeline)
|
|
||||||
- SSE at /meteor/stream for detection events and stats
|
|
||||||
- REST endpoints for status, events, and export
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import queue
|
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from contextlib import suppress
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Flask, Response, jsonify, request
|
|
||||||
|
|
||||||
from utils.responses import api_error
|
|
||||||
|
|
||||||
try:
|
|
||||||
from flask_sock import Sock
|
|
||||||
WEBSOCKET_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
WEBSOCKET_AVAILABLE = False
|
|
||||||
Sock = None
|
|
||||||
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.meteor_detector import MeteorDetector
|
|
||||||
from utils.process import register_process, safe_terminate, unregister_process
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import validate_device_index, validate_frequency, validate_gain
|
|
||||||
from utils.waterfall_fft import (
|
|
||||||
build_binary_frame,
|
|
||||||
compute_power_spectrum,
|
|
||||||
cu8_to_complex,
|
|
||||||
quantize_to_uint8,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = get_logger('intercept.meteor')
|
|
||||||
|
|
||||||
# Module-level shared state
|
|
||||||
_state_lock = threading.Lock()
|
|
||||||
_state: dict[str, Any] = {
|
|
||||||
'running': False,
|
|
||||||
'device': None,
|
|
||||||
'frequency_mhz': 0.0,
|
|
||||||
'sample_rate': 0,
|
|
||||||
}
|
|
||||||
_detector: MeteorDetector | None = None
|
|
||||||
_sse_queue: queue.Queue = queue.Queue(maxsize=500)
|
|
||||||
|
|
||||||
# Maximum bandwidth per SDR type (Hz)
|
|
||||||
MAX_BANDWIDTH = {
|
|
||||||
SDRType.RTL_SDR: 2400000,
|
|
||||||
SDRType.HACKRF: 20000000,
|
|
||||||
SDRType.LIME_SDR: 20000000,
|
|
||||||
SDRType.AIRSPY: 10000000,
|
|
||||||
SDRType.SDRPLAY: 2000000,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _push_sse(data: dict[str, Any]) -> None:
|
|
||||||
"""Push a message to the SSE queue, dropping oldest if full."""
|
|
||||||
try:
|
|
||||||
_sse_queue.put_nowait(data)
|
|
||||||
except queue.Full:
|
|
||||||
try:
|
|
||||||
_sse_queue.get_nowait()
|
|
||||||
_sse_queue.put_nowait(data)
|
|
||||||
except (queue.Empty, queue.Full):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
|
||||||
mapping = {
|
|
||||||
'rtlsdr': SDRType.RTL_SDR,
|
|
||||||
'rtl_sdr': SDRType.RTL_SDR,
|
|
||||||
'hackrf': SDRType.HACKRF,
|
|
||||||
'limesdr': SDRType.LIME_SDR,
|
|
||||||
'airspy': SDRType.AIRSPY,
|
|
||||||
'sdrplay': SDRType.SDRPLAY,
|
|
||||||
}
|
|
||||||
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
caps = builder.get_capabilities()
|
|
||||||
return SDRDevice(
|
|
||||||
sdr_type=sdr_type,
|
|
||||||
index=device_index,
|
|
||||||
name=f'{sdr_type.value}-{device_index}',
|
|
||||||
serial='N/A',
|
|
||||||
driver=sdr_type.value,
|
|
||||||
capabilities=caps,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
|
|
||||||
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
|
|
||||||
if valid_rates:
|
|
||||||
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
|
|
||||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
|
||||||
return max(62500, min(span_hz, max_bw))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Blueprint for REST/SSE endpoints ──
|
|
||||||
|
|
||||||
meteor_bp = Blueprint('meteor', __name__, url_prefix='/meteor')
|
|
||||||
|
|
||||||
|
|
||||||
@meteor_bp.route('/status')
|
|
||||||
def meteor_status():
|
|
||||||
"""Return current meteor monitoring status."""
|
|
||||||
with _state_lock:
|
|
||||||
running = _state['running']
|
|
||||||
freq = _state['frequency_mhz']
|
|
||||||
device = _state['device']
|
|
||||||
sr = _state['sample_rate']
|
|
||||||
|
|
||||||
detector = _detector
|
|
||||||
stats = None
|
|
||||||
if detector:
|
|
||||||
stats = detector._build_stats(time.time())
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'running': running,
|
|
||||||
'frequency_mhz': freq,
|
|
||||||
'device': device,
|
|
||||||
'sample_rate': sr,
|
|
||||||
'stats': stats,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@meteor_bp.route('/stream')
|
|
||||||
def meteor_stream():
|
|
||||||
"""SSE endpoint for meteor detection events and stats."""
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=_sse_queue,
|
|
||||||
channel_key='meteor',
|
|
||||||
timeout=1.0,
|
|
||||||
keepalive_interval=30.0,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
)
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@meteor_bp.route('/events')
|
|
||||||
def meteor_events():
|
|
||||||
"""Return detected events as JSON."""
|
|
||||||
detector = _detector
|
|
||||||
if not detector:
|
|
||||||
return jsonify({'events': []})
|
|
||||||
limit = request.args.get('limit', 500, type=int)
|
|
||||||
return jsonify({'events': detector.get_events(limit=limit)})
|
|
||||||
|
|
||||||
|
|
||||||
@meteor_bp.route('/events/export')
|
|
||||||
def meteor_events_export():
|
|
||||||
"""Export events as CSV or JSON."""
|
|
||||||
detector = _detector
|
|
||||||
if not detector:
|
|
||||||
return api_error('No active session', 400)
|
|
||||||
|
|
||||||
fmt = request.args.get('format', 'json').lower()
|
|
||||||
if fmt == 'csv':
|
|
||||||
csv_data = detector.export_events_csv()
|
|
||||||
return Response(
|
|
||||||
csv_data,
|
|
||||||
mimetype='text/csv',
|
|
||||||
headers={'Content-Disposition': 'attachment; filename=meteor_events.csv'},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
json_data = detector.export_events_json()
|
|
||||||
return Response(
|
|
||||||
json_data,
|
|
||||||
mimetype='application/json',
|
|
||||||
headers={'Content-Disposition': 'attachment; filename=meteor_events.json'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@meteor_bp.route('/events/clear', methods=['POST'])
|
|
||||||
def meteor_events_clear():
|
|
||||||
"""Clear all detected events."""
|
|
||||||
detector = _detector
|
|
||||||
if not detector:
|
|
||||||
return jsonify({'cleared': 0})
|
|
||||||
count = detector.clear_events()
|
|
||||||
return jsonify({'cleared': count})
|
|
||||||
|
|
||||||
|
|
||||||
# ── WebSocket handler ──
|
|
||||||
|
|
||||||
def init_meteor_websocket(app: Flask):
|
|
||||||
"""Initialize WebSocket meteor scatter streaming."""
|
|
||||||
global _detector
|
|
||||||
|
|
||||||
if not WEBSOCKET_AVAILABLE:
|
|
||||||
logger.warning("flask-sock not installed, WebSocket meteor disabled")
|
|
||||||
return
|
|
||||||
|
|
||||||
sock = Sock(app)
|
|
||||||
|
|
||||||
@sock.route('/ws/meteor')
|
|
||||||
def meteor_stream_ws(ws):
|
|
||||||
"""WebSocket endpoint for meteor scatter waterfall + detection."""
|
|
||||||
global _detector
|
|
||||||
logger.info("WebSocket meteor client connected")
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
iq_process = None
|
|
||||||
reader_thread = None
|
|
||||||
stop_event = threading.Event()
|
|
||||||
claimed_device = None
|
|
||||||
claimed_sdr_type = 'rtlsdr'
|
|
||||||
send_queue: queue.Queue = queue.Queue(maxsize=120)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# Drain send queue
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
outgoing = send_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
ws.send(outgoing)
|
|
||||||
except Exception:
|
|
||||||
stop_event.set()
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = ws.receive(timeout=0.01)
|
|
||||||
except Exception as e:
|
|
||||||
err = str(e).lower()
|
|
||||||
if "closed" in err:
|
|
||||||
break
|
|
||||||
if "timed out" not in err:
|
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if msg is None:
|
|
||||||
if not ws.connected:
|
|
||||||
break
|
|
||||||
if stop_event.is_set():
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(msg)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
cmd = data.get('cmd')
|
|
||||||
|
|
||||||
if cmd == 'start':
|
|
||||||
# Stop any existing capture
|
|
||||||
was_restarting = iq_process is not None
|
|
||||||
stop_event.set()
|
|
||||||
if reader_thread and reader_thread.is_alive():
|
|
||||||
reader_thread.join(timeout=2)
|
|
||||||
if iq_process:
|
|
||||||
safe_terminate(iq_process)
|
|
||||||
unregister_process(iq_process)
|
|
||||||
iq_process = None
|
|
||||||
if claimed_device is not None:
|
|
||||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
|
||||||
claimed_device = None
|
|
||||||
with _state_lock:
|
|
||||||
_state['running'] = False
|
|
||||||
stop_event.clear()
|
|
||||||
while not send_queue.empty():
|
|
||||||
try:
|
|
||||||
send_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
break
|
|
||||||
if was_restarting:
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# Parse config
|
|
||||||
try:
|
|
||||||
frequency_mhz = float(data.get('frequency_mhz', 143.05))
|
|
||||||
validate_frequency(frequency_mhz)
|
|
||||||
gain_raw = data.get('gain')
|
|
||||||
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
|
||||||
gain = None
|
|
||||||
else:
|
|
||||||
gain = validate_gain(float(gain_raw))
|
|
||||||
device_index = validate_device_index(int(data.get('device', 0)))
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
sample_rate_req = int(data.get('sample_rate', 250000))
|
|
||||||
fft_size = int(data.get('fft_size', 1024))
|
|
||||||
fps = int(data.get('fps', 20))
|
|
||||||
avg_count = int(data.get('avg_count', 4))
|
|
||||||
ppm = data.get('ppm')
|
|
||||||
if ppm is not None:
|
|
||||||
ppm = int(ppm)
|
|
||||||
bias_t = bool(data.get('bias_t', False))
|
|
||||||
|
|
||||||
# Detection settings
|
|
||||||
snr_threshold = float(data.get('snr_threshold', 6.0))
|
|
||||||
min_duration = float(data.get('min_duration_ms', 50.0))
|
|
||||||
cooldown = float(data.get('cooldown_ms', 200.0))
|
|
||||||
freq_drift = float(data.get('freq_drift_tolerance_hz', 500.0))
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
ws.send(json.dumps({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid configuration: {exc}',
|
|
||||||
}))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Clamp values
|
|
||||||
fft_size = max(256, min(4096, fft_size))
|
|
||||||
fps = max(5, min(30, fps))
|
|
||||||
avg_count = max(1, min(16, avg_count))
|
|
||||||
|
|
||||||
# Resolve SDR type and sample rate
|
|
||||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
caps = builder.get_capabilities()
|
|
||||||
sample_rate = _pick_sample_rate(sample_rate_req, caps, sdr_type)
|
|
||||||
|
|
||||||
# Compute frequency range
|
|
||||||
span_mhz = sample_rate / 1e6
|
|
||||||
start_freq = frequency_mhz - span_mhz / 2
|
|
||||||
end_freq = frequency_mhz + span_mhz / 2
|
|
||||||
|
|
||||||
# Claim SDR device
|
|
||||||
max_claim_attempts = 4 if was_restarting else 1
|
|
||||||
claim_err = None
|
|
||||||
for _attempt in range(max_claim_attempts):
|
|
||||||
claim_err = app_module.claim_sdr_device(device_index, 'meteor', sdr_type_str)
|
|
||||||
if not claim_err:
|
|
||||||
break
|
|
||||||
if _attempt < max_claim_attempts - 1:
|
|
||||||
time.sleep(0.4)
|
|
||||||
if claim_err:
|
|
||||||
ws.send(json.dumps({
|
|
||||||
'status': 'error',
|
|
||||||
'message': claim_err,
|
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
}))
|
|
||||||
continue
|
|
||||||
claimed_device = device_index
|
|
||||||
claimed_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Build I/Q capture command
|
|
||||||
try:
|
|
||||||
device = _build_dummy_device(device_index, sdr_type)
|
|
||||||
iq_cmd = builder.build_iq_capture_command(
|
|
||||||
device=device,
|
|
||||||
frequency_mhz=frequency_mhz,
|
|
||||||
sample_rate=sample_rate,
|
|
||||||
gain=gain,
|
|
||||||
ppm=ppm,
|
|
||||||
bias_t=bias_t,
|
|
||||||
)
|
|
||||||
except NotImplementedError as e:
|
|
||||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
|
||||||
claimed_device = None
|
|
||||||
ws.send(json.dumps({'status': 'error', 'message': str(e)}))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check binary exists
|
|
||||||
if not shutil.which(iq_cmd[0]):
|
|
||||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
|
||||||
claimed_device = None
|
|
||||||
ws.send(json.dumps({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Required tool "{iq_cmd[0]}" not found.',
|
|
||||||
}))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Spawn I/Q capture
|
|
||||||
max_attempts = 3 if was_restarting else 1
|
|
||||||
try:
|
|
||||||
for attempt in range(max_attempts):
|
|
||||||
logger.info(
|
|
||||||
f"Starting meteor I/Q capture: {frequency_mhz:.6f} MHz, "
|
|
||||||
f"sr={sample_rate}, fft={fft_size}"
|
|
||||||
)
|
|
||||||
iq_process = subprocess.Popen(
|
|
||||||
iq_cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
bufsize=0,
|
|
||||||
)
|
|
||||||
register_process(iq_process)
|
|
||||||
|
|
||||||
time.sleep(0.3)
|
|
||||||
if iq_process.poll() is not None:
|
|
||||||
stderr_out = ''
|
|
||||||
if iq_process.stderr:
|
|
||||||
with suppress(Exception):
|
|
||||||
stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip()
|
|
||||||
unregister_process(iq_process)
|
|
||||||
iq_process = None
|
|
||||||
if attempt < max_attempts - 1:
|
|
||||||
time.sleep(0.5)
|
|
||||||
continue
|
|
||||||
detail = f": {stderr_out}" if stderr_out else ""
|
|
||||||
raise RuntimeError(f"I/Q process exited immediately{detail}")
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start meteor I/Q capture: {e}")
|
|
||||||
if iq_process:
|
|
||||||
safe_terminate(iq_process)
|
|
||||||
unregister_process(iq_process)
|
|
||||||
iq_process = None
|
|
||||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
|
||||||
claimed_device = None
|
|
||||||
ws.send(json.dumps({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Failed to start I/Q capture: {e}',
|
|
||||||
}))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Initialize detector
|
|
||||||
_detector = MeteorDetector(
|
|
||||||
snr_threshold_db=snr_threshold,
|
|
||||||
min_duration_ms=min_duration,
|
|
||||||
cooldown_ms=cooldown,
|
|
||||||
freq_drift_tolerance_hz=freq_drift,
|
|
||||||
)
|
|
||||||
|
|
||||||
with _state_lock:
|
|
||||||
_state['running'] = True
|
|
||||||
_state['device'] = device_index
|
|
||||||
_state['frequency_mhz'] = frequency_mhz
|
|
||||||
_state['sample_rate'] = sample_rate
|
|
||||||
|
|
||||||
# Send confirmation
|
|
||||||
ws.send(json.dumps({
|
|
||||||
'status': 'started',
|
|
||||||
'frequency_mhz': frequency_mhz,
|
|
||||||
'start_freq': start_freq,
|
|
||||||
'end_freq': end_freq,
|
|
||||||
'fft_size': fft_size,
|
|
||||||
'sample_rate': sample_rate,
|
|
||||||
'span_mhz': span_mhz,
|
|
||||||
}))
|
|
||||||
|
|
||||||
# Start FFT reader + detection thread
|
|
||||||
def fft_reader(
|
|
||||||
proc, _send_q, stop_evt, detector,
|
|
||||||
_fft_size, _avg_count, _fps, _sample_rate,
|
|
||||||
_start_freq, _end_freq, _freq_mhz,
|
|
||||||
):
|
|
||||||
required_fft_samples = _fft_size * _avg_count
|
|
||||||
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
|
||||||
bytes_per_frame = timeslice_samples * 2
|
|
||||||
frame_interval = 1.0 / _fps
|
|
||||||
start_freq_hz = _start_freq * 1e6
|
|
||||||
end_freq_hz = _end_freq * 1e6
|
|
||||||
last_stats_push = 0.0
|
|
||||||
|
|
||||||
try:
|
|
||||||
while not stop_evt.is_set():
|
|
||||||
if proc.poll() is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
frame_start = time.monotonic()
|
|
||||||
|
|
||||||
# Read raw I/Q
|
|
||||||
raw = b''
|
|
||||||
remaining = bytes_per_frame
|
|
||||||
while remaining > 0 and not stop_evt.is_set():
|
|
||||||
chunk = proc.stdout.read(min(remaining, 65536))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
raw += chunk
|
|
||||||
remaining -= len(chunk)
|
|
||||||
|
|
||||||
if len(raw) < _fft_size * 2:
|
|
||||||
break
|
|
||||||
|
|
||||||
# FFT pipeline
|
|
||||||
samples = cu8_to_complex(raw)
|
|
||||||
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
|
||||||
power_db = compute_power_spectrum(
|
|
||||||
fft_samples,
|
|
||||||
fft_size=_fft_size,
|
|
||||||
avg_count=_avg_count,
|
|
||||||
)
|
|
||||||
quantized = quantize_to_uint8(power_db)
|
|
||||||
frame = build_binary_frame(_start_freq, _end_freq, quantized)
|
|
||||||
|
|
||||||
# Send waterfall frame via WS
|
|
||||||
with suppress(queue.Full):
|
|
||||||
_send_q.put_nowait(frame)
|
|
||||||
|
|
||||||
# Run detection on raw dB spectrum
|
|
||||||
now = time.time()
|
|
||||||
stats, event = detector.process_frame(
|
|
||||||
power_db, start_freq_hz, end_freq_hz, now,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Push event immediately via SSE
|
|
||||||
if event:
|
|
||||||
_push_sse({
|
|
||||||
'type': 'event',
|
|
||||||
'event': event.to_dict(),
|
|
||||||
})
|
|
||||||
# Also send as JSON via WS for immediate UI update
|
|
||||||
event_msg = json.dumps({
|
|
||||||
'type': 'detection',
|
|
||||||
'event': event.to_dict(),
|
|
||||||
})
|
|
||||||
with suppress(queue.Full):
|
|
||||||
_send_q.put_nowait(event_msg)
|
|
||||||
|
|
||||||
# Push stats every ~1s via SSE
|
|
||||||
if now - last_stats_push >= 1.0:
|
|
||||||
_push_sse(stats)
|
|
||||||
last_stats_push = now
|
|
||||||
|
|
||||||
# Pace to target FPS
|
|
||||||
elapsed = time.monotonic() - frame_start
|
|
||||||
sleep_time = frame_interval - elapsed
|
|
||||||
if sleep_time > 0:
|
|
||||||
stop_evt.wait(sleep_time)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Meteor FFT reader stopped: {e}")
|
|
||||||
|
|
||||||
reader_thread = threading.Thread(
|
|
||||||
target=fft_reader,
|
|
||||||
args=(
|
|
||||||
iq_process, send_queue, stop_event, _detector,
|
|
||||||
fft_size, avg_count, fps, sample_rate,
|
|
||||||
start_freq, end_freq, frequency_mhz,
|
|
||||||
),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
reader_thread.start()
|
|
||||||
|
|
||||||
elif cmd == 'update_threshold':
|
|
||||||
detector = _detector
|
|
||||||
if detector:
|
|
||||||
detector.update_settings(
|
|
||||||
snr_threshold_db=data.get('snr_threshold'),
|
|
||||||
min_duration_ms=data.get('min_duration_ms'),
|
|
||||||
cooldown_ms=data.get('cooldown_ms'),
|
|
||||||
freq_drift_tolerance_hz=data.get('freq_drift_tolerance_hz'),
|
|
||||||
)
|
|
||||||
ws.send(json.dumps({'status': 'threshold_updated'}))
|
|
||||||
|
|
||||||
elif cmd == 'stop':
|
|
||||||
stop_event.set()
|
|
||||||
if reader_thread and reader_thread.is_alive():
|
|
||||||
reader_thread.join(timeout=2)
|
|
||||||
reader_thread = None
|
|
||||||
if iq_process:
|
|
||||||
safe_terminate(iq_process)
|
|
||||||
unregister_process(iq_process)
|
|
||||||
iq_process = None
|
|
||||||
if claimed_device is not None:
|
|
||||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
|
||||||
claimed_device = None
|
|
||||||
with _state_lock:
|
|
||||||
_state['running'] = False
|
|
||||||
_state['device'] = None
|
|
||||||
stop_event.clear()
|
|
||||||
ws.send(json.dumps({'status': 'stopped'}))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(f"WebSocket meteor closed: {e}")
|
|
||||||
finally:
|
|
||||||
stop_event.set()
|
|
||||||
if reader_thread and reader_thread.is_alive():
|
|
||||||
reader_thread.join(timeout=2)
|
|
||||||
if iq_process:
|
|
||||||
safe_terminate(iq_process)
|
|
||||||
unregister_process(iq_process)
|
|
||||||
if claimed_device is not None:
|
|
||||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
|
||||||
with _state_lock:
|
|
||||||
_state['running'] = False
|
|
||||||
_state['device'] = None
|
|
||||||
with suppress(Exception):
|
|
||||||
ws.close()
|
|
||||||
with suppress(Exception):
|
|
||||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
|
||||||
with suppress(Exception):
|
|
||||||
ws.sock.close()
|
|
||||||
logger.info("WebSocket meteor client disconnected")
|
|
||||||
@@ -2,21 +2,17 @@
|
|||||||
Offline mode routes - Asset management and settings for offline operation.
|
Offline mode routes - Asset management and settings for offline operation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
from flask import Blueprint, request
|
|
||||||
|
|
||||||
from utils.database import get_setting, set_setting
|
from utils.database import get_setting, set_setting
|
||||||
from utils.responses import api_error, api_success
|
import os
|
||||||
|
|
||||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||||
|
|
||||||
# Default offline settings
|
# Default offline settings
|
||||||
OFFLINE_DEFAULTS = {
|
OFFLINE_DEFAULTS = {
|
||||||
'offline.enabled': False,
|
'offline.enabled': False,
|
||||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
'offline.assets_source': 'cdn',
|
||||||
'offline.assets_source': 'local',
|
'offline.fonts_source': 'cdn',
|
||||||
'offline.fonts_source': 'local',
|
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
}
|
}
|
||||||
@@ -48,9 +44,6 @@ ASSET_PATHS = {
|
|||||||
'static/vendor/leaflet/images/marker-shadow.png',
|
'static/vendor/leaflet/images/marker-shadow.png',
|
||||||
'static/vendor/leaflet/images/layers.png',
|
'static/vendor/leaflet/images/layers.png',
|
||||||
'static/vendor/leaflet/images/layers-2x.png'
|
'static/vendor/leaflet/images/layers-2x.png'
|
||||||
],
|
|
||||||
'leaflet_heat': [
|
|
||||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +60,10 @@ def get_offline_settings():
|
|||||||
def get_settings():
|
def get_settings():
|
||||||
"""Get current offline settings."""
|
"""Get current offline settings."""
|
||||||
settings = get_offline_settings()
|
settings = get_offline_settings()
|
||||||
return api_success(data={'settings': settings})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'settings': settings
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/settings', methods=['POST'])
|
@offline_bp.route('/settings', methods=['POST'])
|
||||||
@@ -75,14 +71,14 @@ def save_setting():
|
|||||||
"""Save an offline setting."""
|
"""Save an offline setting."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'key' not in data or 'value' not in data:
|
if not data or 'key' not in data or 'value' not in data:
|
||||||
return api_error('Missing key or value', 400)
|
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
||||||
|
|
||||||
key = data['key']
|
key = data['key']
|
||||||
value = data['value']
|
value = data['value']
|
||||||
|
|
||||||
# Validate key is an allowed setting
|
# Validate key is an allowed setting
|
||||||
if key not in OFFLINE_DEFAULTS:
|
if key not in OFFLINE_DEFAULTS:
|
||||||
return api_error(f'Unknown setting: {key}', 400)
|
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
|
||||||
|
|
||||||
# Validate value type matches default
|
# Validate value type matches default
|
||||||
default_type = type(OFFLINE_DEFAULTS[key])
|
default_type = type(OFFLINE_DEFAULTS[key])
|
||||||
@@ -94,11 +90,18 @@ def save_setting():
|
|||||||
else:
|
else:
|
||||||
value = default_type(value)
|
value = default_type(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return api_error(f'Invalid value type for {key}', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid value type for {key}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
set_setting(key, value)
|
set_setting(key, value)
|
||||||
|
|
||||||
return api_success(data={'key': key, 'value': value})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'value': value
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/status', methods=['GET'])
|
@offline_bp.route('/status', methods=['GET'])
|
||||||
@@ -127,7 +130,8 @@ def get_status():
|
|||||||
if not available:
|
if not available:
|
||||||
all_available = False
|
all_available = False
|
||||||
|
|
||||||
return api_success(data={
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
'all_available': all_available,
|
'all_available': all_available,
|
||||||
'assets': results,
|
'assets': results,
|
||||||
'offline_enabled': get_setting('offline.enabled', False)
|
'offline_enabled': get_setting('offline.enabled', False)
|
||||||
@@ -139,11 +143,11 @@ def check_asset():
|
|||||||
"""Check if a specific asset file exists."""
|
"""Check if a specific asset file exists."""
|
||||||
path = request.args.get('path', '')
|
path = request.args.get('path', '')
|
||||||
if not path:
|
if not path:
|
||||||
return api_error('Missing path parameter', 400)
|
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
||||||
|
|
||||||
# Security: only allow checking within static/vendor
|
# Security: only allow checking within static/vendor
|
||||||
if not path.startswith('/static/vendor/'):
|
if not path.startswith('/static/vendor/'):
|
||||||
return api_error('Invalid path', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
|
||||||
|
|
||||||
# Remove leading slash and construct full path
|
# Remove leading slash and construct full path
|
||||||
relative_path = path.lstrip('/')
|
relative_path = path.lstrip('/')
|
||||||
@@ -152,4 +156,8 @@ def check_asset():
|
|||||||
|
|
||||||
exists = os.path.exists(full_path)
|
exists = os.path.exists(full_path)
|
||||||
|
|
||||||
return api_success(data={'path': path, 'exists': exists})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'path': path,
|
||||||
|
'exists': exists
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
"""Generic OOK signal decoder routes.
|
|
||||||
|
|
||||||
Captures raw OOK frames using rtl_433's flex decoder and streams decoded
|
|
||||||
bit/hex data to the browser for live ASCII interpretation. Supports
|
|
||||||
PWM, PPM, and Manchester modulation with fully configurable pulse timing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import sensor_logger as logger
|
|
||||||
from utils.ook import ook_parser_thread
|
|
||||||
from utils.process import register_process, safe_terminate, unregister_process
|
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import (
|
|
||||||
validate_device_index,
|
|
||||||
validate_frequency,
|
|
||||||
validate_gain,
|
|
||||||
validate_positive_int,
|
|
||||||
validate_ppm,
|
|
||||||
validate_rtl_tcp_host,
|
|
||||||
validate_rtl_tcp_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
ook_bp = Blueprint('ook', __name__)
|
|
||||||
|
|
||||||
# Track which device / SDR type is being used
|
|
||||||
ook_active_device: int | None = None
|
|
||||||
ook_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
# Parser thread state (avoids monkey-patching subprocess.Popen)
|
|
||||||
_ook_stop_event: threading.Event | None = None
|
|
||||||
_ook_parser_thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
# Supported modulation schemes → rtl_433 flex decoder modulation string
|
|
||||||
_MODULATION_MAP = {
|
|
||||||
'pwm': 'OOK_PWM',
|
|
||||||
'ppm': 'OOK_PPM',
|
|
||||||
'manchester': 'OOK_MC_ZEROBIT',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_encoding(value: Any) -> str:
|
|
||||||
enc = str(value).lower().strip()
|
|
||||||
if enc not in _MODULATION_MAP:
|
|
||||||
raise ValueError(f"encoding must be one of: {', '.join(_MODULATION_MAP)}")
|
|
||||||
return enc
|
|
||||||
|
|
||||||
|
|
||||||
@ook_bp.route('/ook/start', methods=['POST'])
|
|
||||||
def start_ook() -> Response:
|
|
||||||
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
|
|
||||||
|
|
||||||
with app_module.ook_lock:
|
|
||||||
if app_module.ook_process:
|
|
||||||
# If the process exited/crashed, clean up stale state and allow restart
|
|
||||||
if app_module.ook_process.poll() is not None:
|
|
||||||
cleanup_ook(emit_status=False)
|
|
||||||
else:
|
|
||||||
return api_error('OOK decoder already running', 409)
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
freq = validate_frequency(data.get('frequency', '433.920'))
|
|
||||||
gain = validate_gain(data.get('gain', '0'))
|
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
|
||||||
device = validate_device_index(data.get('device', '0'))
|
|
||||||
except ValueError as e:
|
|
||||||
return api_error(str(e), 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
|
||||||
except ValueError as e:
|
|
||||||
return api_error(str(e), 400)
|
|
||||||
|
|
||||||
# OOK flex decoder timing parameters (server-side range validation)
|
|
||||||
try:
|
|
||||||
short_pulse = validate_positive_int(data.get('short_pulse', 300), 'short_pulse', max_val=100000)
|
|
||||||
long_pulse = validate_positive_int(data.get('long_pulse', 600), 'long_pulse', max_val=100000)
|
|
||||||
reset_limit = validate_positive_int(data.get('reset_limit', 8000), 'reset_limit', max_val=1000000)
|
|
||||||
gap_limit = validate_positive_int(data.get('gap_limit', 5000), 'gap_limit', max_val=1000000)
|
|
||||||
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
|
|
||||||
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
|
|
||||||
except ValueError as e:
|
|
||||||
return api_error(f'Invalid timing parameter: {e}', 400)
|
|
||||||
if min_bits < 1:
|
|
||||||
return api_error('min_bits must be >= 1', 400)
|
|
||||||
if short_pulse < 1 or long_pulse < 1:
|
|
||||||
return api_error('Pulse widths must be >= 1', 400)
|
|
||||||
deduplicate = bool(data.get('deduplicate', False))
|
|
||||||
|
|
||||||
# Parse SDR type early — needed for device claim
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
sdr_type_str = 'rtlsdr'
|
|
||||||
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host') or None
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
if not rtl_tcp_host:
|
|
||||||
device_int = int(device)
|
|
||||||
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
|
|
||||||
if error:
|
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
|
||||||
ook_active_device = device_int
|
|
||||||
ook_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
while not app_module.ook_queue.empty():
|
|
||||||
try:
|
|
||||||
app_module.ook_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
break
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
|
||||||
try:
|
|
||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
|
||||||
except ValueError as e:
|
|
||||||
return api_error(str(e), 400)
|
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
|
||||||
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
|
|
||||||
else:
|
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
|
||||||
|
|
||||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
|
||||||
bias_t = data.get('bias_t', False)
|
|
||||||
|
|
||||||
# Build base ISM command then replace protocol flags with flex decoder
|
|
||||||
cmd = builder.build_ism_command(
|
|
||||||
device=sdr_device,
|
|
||||||
frequency_mhz=freq,
|
|
||||||
gain=float(gain) if gain and gain != 0 else None,
|
|
||||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
|
||||||
bias_t=bias_t,
|
|
||||||
)
|
|
||||||
|
|
||||||
modulation = _MODULATION_MAP[encoding]
|
|
||||||
flex_spec = (
|
|
||||||
f'n=ook,m={modulation},'
|
|
||||||
f's={short_pulse},l={long_pulse},'
|
|
||||||
f'r={reset_limit},g={gap_limit},'
|
|
||||||
f't={tolerance},bits>={min_bits}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strip any existing -R flags from the base command
|
|
||||||
filtered_cmd: list[str] = []
|
|
||||||
skip_next = False
|
|
||||||
for arg in cmd:
|
|
||||||
if skip_next:
|
|
||||||
skip_next = False
|
|
||||||
continue
|
|
||||||
if arg == '-R':
|
|
||||||
skip_next = True
|
|
||||||
continue
|
|
||||||
filtered_cmd.append(arg)
|
|
||||||
|
|
||||||
filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec])
|
|
||||||
|
|
||||||
full_cmd = ' '.join(filtered_cmd)
|
|
||||||
logger.info(f'OOK decoder running: {full_cmd}')
|
|
||||||
|
|
||||||
try:
|
|
||||||
rtl_process = subprocess.Popen(
|
|
||||||
filtered_cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
register_process(rtl_process)
|
|
||||||
|
|
||||||
_stderr_noise = ('bitbuffer_add_bit', 'row count limit')
|
|
||||||
|
|
||||||
def monitor_stderr() -> None:
|
|
||||||
for line in rtl_process.stderr:
|
|
||||||
err_text = line.decode('utf-8', errors='replace').strip()
|
|
||||||
if err_text and not any(n in err_text for n in _stderr_noise):
|
|
||||||
logger.debug(f'[rtl_433/ook] {err_text}')
|
|
||||||
|
|
||||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
|
||||||
stderr_thread.daemon = True
|
|
||||||
stderr_thread.start()
|
|
||||||
|
|
||||||
stop_event = threading.Event()
|
|
||||||
parser_thread = threading.Thread(
|
|
||||||
target=ook_parser_thread,
|
|
||||||
args=(
|
|
||||||
rtl_process.stdout,
|
|
||||||
app_module.ook_queue,
|
|
||||||
stop_event,
|
|
||||||
encoding,
|
|
||||||
deduplicate,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser_thread.daemon = True
|
|
||||||
parser_thread.start()
|
|
||||||
|
|
||||||
app_module.ook_process = rtl_process
|
|
||||||
_ook_stop_event = stop_event
|
|
||||||
_ook_parser_thread = parser_thread
|
|
||||||
|
|
||||||
try:
|
|
||||||
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'started'})
|
|
||||||
except queue.Full:
|
|
||||||
logger.warning("OOK 'started' status dropped — queue full")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'command': full_cmd,
|
|
||||||
'encoding': encoding,
|
|
||||||
'modulation': modulation,
|
|
||||||
'flex_spec': flex_spec,
|
|
||||||
'deduplicate': deduplicate,
|
|
||||||
})
|
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
if ook_active_device is not None:
|
|
||||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
|
||||||
ook_active_device = None
|
|
||||||
ook_active_sdr_type = None
|
|
||||||
return api_error(f'Tool not found: {e.filename}', 400)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
rtl_process.terminate()
|
|
||||||
rtl_process.wait(timeout=2)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
rtl_process.kill()
|
|
||||||
unregister_process(rtl_process)
|
|
||||||
if ook_active_device is not None:
|
|
||||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
|
||||||
ook_active_device = None
|
|
||||||
ook_active_sdr_type = None
|
|
||||||
return api_error(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
def _close_pipe(pipe_obj) -> None:
|
|
||||||
"""Close a subprocess pipe, suppressing errors."""
|
|
||||||
if pipe_obj is not None:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
pipe_obj.close()
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_ook(*, emit_status: bool = True) -> None:
|
|
||||||
"""Full OOK cleanup: stop parser, terminate process, release SDR device.
|
|
||||||
|
|
||||||
Safe to call from ``stop_ook()`` and ``kill_all()``. Caller must hold
|
|
||||||
``app_module.ook_lock``.
|
|
||||||
"""
|
|
||||||
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
|
|
||||||
|
|
||||||
proc = app_module.ook_process
|
|
||||||
if not proc:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Signal parser thread to stop
|
|
||||||
if _ook_stop_event:
|
|
||||||
_ook_stop_event.set()
|
|
||||||
|
|
||||||
# Close pipes so parser thread unblocks from readline()
|
|
||||||
_close_pipe(getattr(proc, 'stdout', None))
|
|
||||||
_close_pipe(getattr(proc, 'stderr', None))
|
|
||||||
|
|
||||||
# Kill the entire process group so child processes are cleaned up
|
|
||||||
try:
|
|
||||||
pgid = os.getpgid(proc.pid)
|
|
||||||
os.killpg(pgid, signal.SIGTERM)
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=2)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
os.killpg(pgid, signal.SIGKILL)
|
|
||||||
proc.wait(timeout=3)
|
|
||||||
except (ProcessLookupError, OSError):
|
|
||||||
# Process already dead — fall back to normal terminate
|
|
||||||
safe_terminate(proc)
|
|
||||||
unregister_process(proc)
|
|
||||||
app_module.ook_process = None
|
|
||||||
|
|
||||||
# Join parser thread with timeout
|
|
||||||
if _ook_parser_thread:
|
|
||||||
_ook_parser_thread.join(timeout=0.5)
|
|
||||||
|
|
||||||
_ook_stop_event = None
|
|
||||||
_ook_parser_thread = None
|
|
||||||
|
|
||||||
if ook_active_device is not None:
|
|
||||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
|
||||||
ook_active_device = None
|
|
||||||
ook_active_sdr_type = None
|
|
||||||
|
|
||||||
if emit_status:
|
|
||||||
try:
|
|
||||||
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'stopped'})
|
|
||||||
except queue.Full:
|
|
||||||
logger.warning("OOK 'stopped' status dropped — queue full")
|
|
||||||
|
|
||||||
|
|
||||||
@ook_bp.route('/ook/stop', methods=['POST'])
|
|
||||||
def stop_ook() -> Response:
|
|
||||||
with app_module.ook_lock:
|
|
||||||
if app_module.ook_process:
|
|
||||||
cleanup_ook()
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
return jsonify({'status': 'not_running'})
|
|
||||||
|
|
||||||
|
|
||||||
@ook_bp.route('/ook/status')
|
|
||||||
def ook_status() -> Response:
|
|
||||||
with app_module.ook_lock:
|
|
||||||
running = (
|
|
||||||
app_module.ook_process is not None
|
|
||||||
and app_module.ook_process.poll() is None
|
|
||||||
)
|
|
||||||
return jsonify({'running': running})
|
|
||||||
|
|
||||||
|
|
||||||
@ook_bp.route('/ook/stream')
|
|
||||||
def ook_stream() -> Response:
|
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
|
||||||
process_event('ook', msg, msg.get('type'))
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=app_module.ook_queue,
|
|
||||||
channel_key='ook',
|
|
||||||
timeout=1.0,
|
|
||||||
keepalive_interval=30.0,
|
|
||||||
on_message=_on_msg,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
)
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
|
||||||
@@ -2,45 +2,35 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
import pty
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import re
|
|
||||||
import select
|
import select
|
||||||
import struct
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import get_tool_path
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import pager_logger as logger
|
from utils.logging import pager_logger as logger
|
||||||
from utils.process import register_process, unregister_process
|
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_frequency,
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
validate_gain,
|
|
||||||
validate_ppm,
|
|
||||||
validate_rtl_tcp_host,
|
|
||||||
validate_rtl_tcp_port,
|
|
||||||
)
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
pager_bp = Blueprint('pager', __name__)
|
pager_bp = Blueprint('pager', __name__)
|
||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
pager_active_device: int | None = None
|
pager_active_device: int | None = None
|
||||||
pager_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||||
@@ -61,20 +51,6 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
|||||||
'message': pocsag_match.group(5).strip() or '[No Message]'
|
'message': pocsag_match.group(5).strip() or '[No Message]'
|
||||||
}
|
}
|
||||||
|
|
||||||
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
|
|
||||||
pocsag_other_match = re.match(
|
|
||||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)',
|
|
||||||
line
|
|
||||||
)
|
|
||||||
if pocsag_other_match:
|
|
||||||
return {
|
|
||||||
'protocol': pocsag_other_match.group(1),
|
|
||||||
'address': pocsag_other_match.group(2),
|
|
||||||
'function': pocsag_other_match.group(3),
|
|
||||||
'msg_type': pocsag_other_match.group(4),
|
|
||||||
'message': pocsag_other_match.group(5).strip() or '[No Message]'
|
|
||||||
}
|
|
||||||
|
|
||||||
# POCSAG parsing - address only (no message content)
|
# POCSAG parsing - address only (no message content)
|
||||||
pocsag_addr_match = re.match(
|
pocsag_addr_match = re.match(
|
||||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
||||||
@@ -129,75 +105,6 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
|
||||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
|
||||||
if not samples:
|
|
||||||
return []
|
|
||||||
|
|
||||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
|
||||||
waveform: list[int] = []
|
|
||||||
for sample in window:
|
|
||||||
# Convert int16 PCM to int8 range for lightweight transport.
|
|
||||||
packed = int(round(sample / 256))
|
|
||||||
waveform.append(max(-127, min(127, packed)))
|
|
||||||
return waveform
|
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
|
||||||
rtl_stdout,
|
|
||||||
multimon_stdin,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
stop_event: threading.Event,
|
|
||||||
) -> None:
|
|
||||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
|
||||||
|
|
||||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
|
||||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
|
||||||
event plus a compact waveform sample onto *output_queue*.
|
|
||||||
"""
|
|
||||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
|
||||||
last_scope = time.monotonic()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while not stop_event.is_set():
|
|
||||||
data = rtl_stdout.read(CHUNK)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Forward audio untouched
|
|
||||||
try:
|
|
||||||
multimon_stdin.write(data)
|
|
||||||
multimon_stdin.flush()
|
|
||||||
except (BrokenPipeError, OSError):
|
|
||||||
break
|
|
||||||
|
|
||||||
# Compute scope levels every ~100 ms
|
|
||||||
now = time.monotonic()
|
|
||||||
if now - last_scope >= INTERVAL:
|
|
||||||
last_scope = now
|
|
||||||
try:
|
|
||||||
n_samples = len(data) // 2
|
|
||||||
if n_samples == 0:
|
|
||||||
continue
|
|
||||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
|
||||||
peak = max(abs(s) for s in samples)
|
|
||||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
|
||||||
output_queue.put_nowait({
|
|
||||||
'type': 'scope',
|
|
||||||
'rms': rms,
|
|
||||||
'peak': peak,
|
|
||||||
'waveform': _encode_scope_waveform(samples),
|
|
||||||
})
|
|
||||||
except (struct.error, ValueError, queue.Full):
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Audio relay error: {e}")
|
|
||||||
finally:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
multimon_stdin.close()
|
|
||||||
|
|
||||||
|
|
||||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||||
try:
|
try:
|
||||||
@@ -239,14 +146,11 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global pager_active_device, pager_active_sdr_type
|
global pager_active_device
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
# Signal relay thread to stop
|
except OSError:
|
||||||
with app_module.process_lock:
|
pass
|
||||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
|
||||||
if stop_relay:
|
|
||||||
stop_relay.set()
|
|
||||||
# Cleanup companion rtl_fm process and decoder
|
# Cleanup companion rtl_fm process and decoder
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||||
@@ -256,26 +160,27 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
unregister_process(proc)
|
unregister_process(proc)
|
||||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device)
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/start', methods=['POST'])
|
@pager_bp.route('/start', methods=['POST'])
|
||||||
def start_decoding() -> Response:
|
def start_decoding() -> Response:
|
||||||
global pager_active_device, pager_active_sdr_type
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
return api_error('Already running', 409)
|
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -286,7 +191,7 @@ def start_decoding() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
squelch = data.get('squelch', '0')
|
squelch = data.get('squelch', '0')
|
||||||
try:
|
try:
|
||||||
@@ -294,33 +199,32 @@ def start_decoding() -> Response:
|
|||||||
if not 0 <= squelch <= 1000:
|
if not 0 <= squelch <= 1000:
|
||||||
raise ValueError("Squelch must be between 0 and 1000")
|
raise ValueError("Squelch must be between 0 and 1000")
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return api_error('Invalid squelch value', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
# Get SDR type early so we can pass it to claim/release
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
|
|
||||||
# Claim local device if not using remote rtl_tcp
|
# Claim local device if not using remote rtl_tcp
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||||
if error:
|
if error:
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
pager_active_device = device_int
|
pager_active_device = device_int
|
||||||
pager_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Validate protocols
|
# Validate protocols
|
||||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||||
protocols = data.get('protocols', valid_protocols)
|
protocols = data.get('protocols', valid_protocols)
|
||||||
if not isinstance(protocols, list):
|
if not isinstance(protocols, list):
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device)
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||||
return api_error('Protocols must be a list', 400)
|
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
protocols = valid_protocols
|
protocols = valid_protocols
|
||||||
@@ -344,7 +248,8 @@ def start_decoding() -> Response:
|
|||||||
elif proto == 'FLEX':
|
elif proto == 'FLEX':
|
||||||
decoders.extend(['-a', 'FLEX'])
|
decoders.extend(['-a', 'FLEX'])
|
||||||
|
|
||||||
# Build command via SDR abstraction layer
|
# Get SDR type and build command via abstraction layer
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -356,7 +261,7 @@ def start_decoding() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
@@ -381,7 +286,7 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_path = get_tool_path('multimon-ng')
|
multimon_path = get_tool_path('multimon-ng')
|
||||||
if not multimon_path:
|
if not multimon_path:
|
||||||
return api_error('multimon-ng not found', 400)
|
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
|
||||||
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||||
|
|
||||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||||
@@ -413,7 +318,7 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_process = subprocess.Popen(
|
multimon_process = subprocess.Popen(
|
||||||
multimon_cmd,
|
multimon_cmd,
|
||||||
stdin=subprocess.PIPE,
|
stdin=rtl_process.stdout,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
@@ -421,22 +326,11 @@ def start_decoding() -> Response:
|
|||||||
register_process(multimon_process)
|
register_process(multimon_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
|
rtl_process.stdout.close()
|
||||||
# Spawn audio relay thread between rtl_fm and multimon-ng
|
|
||||||
stop_relay = threading.Event()
|
|
||||||
relay = threading.Thread(
|
|
||||||
target=audio_relay_thread,
|
|
||||||
args=(rtl_process.stdout, multimon_process.stdin,
|
|
||||||
app_module.output_queue, stop_relay),
|
|
||||||
)
|
|
||||||
relay.daemon = True
|
|
||||||
relay.start()
|
|
||||||
|
|
||||||
app_module.current_process = multimon_process
|
app_module.current_process = multimon_process
|
||||||
app_module.current_process._rtl_process = rtl_process
|
app_module.current_process._rtl_process = rtl_process
|
||||||
app_module.current_process._master_fd = master_fd
|
app_module.current_process._master_fd = master_fd
|
||||||
app_module.current_process._stop_relay = stop_relay
|
|
||||||
app_module.current_process._relay_thread = relay
|
|
||||||
|
|
||||||
# Start output thread with PTY master fd
|
# Start output thread with PTY master fd
|
||||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||||
@@ -453,53 +347,55 @@ def start_decoding() -> Response:
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device)
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
return api_error(f'Tool not found: {e.filename}')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Kill orphaned rtl_fm process if it was started
|
# Kill orphaned rtl_fm process if it was started
|
||||||
try:
|
try:
|
||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=2)
|
rtl_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device)
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
return api_error(str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
global pager_active_device, pager_active_sdr_type
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
# Signal audio relay thread to stop
|
|
||||||
if hasattr(app_module.current_process, '_stop_relay'):
|
|
||||||
app_module.current_process._stop_relay.set()
|
|
||||||
|
|
||||||
# Kill rtl_fm process first
|
# Kill rtl_fm process first
|
||||||
if hasattr(app_module.current_process, '_rtl_process'):
|
if hasattr(app_module.current_process, '_rtl_process'):
|
||||||
try:
|
try:
|
||||||
app_module.current_process._rtl_process.terminate()
|
app_module.current_process._rtl_process.terminate()
|
||||||
app_module.current_process._rtl_process.wait(timeout=2)
|
app_module.current_process._rtl_process.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
app_module.current_process._rtl_process.kill()
|
app_module.current_process._rtl_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Close PTY master fd
|
# Close PTY master fd
|
||||||
if hasattr(app_module.current_process, '_master_fd'):
|
if hasattr(app_module.current_process, '_master_fd'):
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.close(app_module.current_process._master_fd)
|
os.close(app_module.current_process._master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Kill multimon-ng
|
# Kill multimon-ng
|
||||||
app_module.current_process.terminate()
|
app_module.current_process.terminate()
|
||||||
@@ -512,9 +408,8 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(pager_active_device)
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
pager_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -550,35 +445,40 @@ def toggle_logging() -> Response:
|
|||||||
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
||||||
|
|
||||||
if not (is_in_cwd or is_in_logs):
|
if not (is_in_cwd or is_in_logs):
|
||||||
return api_error('Invalid log file path', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||||
|
|
||||||
# Ensure it's not a directory
|
# Ensure it's not a directory
|
||||||
if requested_path.is_dir():
|
if requested_path.is_dir():
|
||||||
return api_error('Log file path must be a file, not a directory', 400)
|
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400
|
||||||
|
|
||||||
app_module.log_file_path = str(requested_path)
|
app_module.log_file_path = str(requested_path)
|
||||||
except (ValueError, OSError) as e:
|
except (ValueError, OSError) as e:
|
||||||
logger.warning(f"Invalid log file path: {e}")
|
logger.warning(f"Invalid log file path: {e}")
|
||||||
return api_error('Invalid log file path', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||||
|
|
||||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stream')
|
@pager_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
import json
|
||||||
process_event('pager', msg, msg.get('type'))
|
|
||||||
|
|
||||||
response = Response(
|
def generate() -> Generator[str, None, None]:
|
||||||
sse_stream_fanout(
|
last_keepalive = time.time()
|
||||||
source_queue=app_module.output_queue,
|
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||||
channel_key='pager',
|
|
||||||
timeout=1.0,
|
while True:
|
||||||
keepalive_interval=30.0,
|
try:
|
||||||
on_message=_on_msg,
|
msg = app_module.output_queue.get(timeout=1)
|
||||||
),
|
last_keepalive = time.time()
|
||||||
mimetype='text/event-stream',
|
yield format_sse(msg)
|
||||||
)
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -1,709 +0,0 @@
|
|||||||
"""Radiosonde weather balloon tracking routes.
|
|
||||||
|
|
||||||
Uses radiosonde_auto_rx to automatically scan for and decode radiosonde
|
|
||||||
telemetry (position, altitude, temperature, humidity, pressure) on the
|
|
||||||
400-406 MHz band. Telemetry arrives as JSON over UDP.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
from utils.constants import (
|
|
||||||
MAX_RADIOSONDE_AGE_SECONDS,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
RADIOSONDE_TERMINATE_TIMEOUT,
|
|
||||||
RADIOSONDE_UDP_PORT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
)
|
|
||||||
from utils.gps import is_gpsd_running
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import (
|
|
||||||
validate_device_index,
|
|
||||||
validate_gain,
|
|
||||||
validate_latitude,
|
|
||||||
validate_longitude,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = get_logger('intercept.radiosonde')
|
|
||||||
|
|
||||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
|
||||||
|
|
||||||
# Track radiosonde state
|
|
||||||
radiosonde_running = False
|
|
||||||
radiosonde_active_device: int | None = None
|
|
||||||
radiosonde_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
# Active balloon data: serial -> telemetry dict
|
|
||||||
radiosonde_balloons: dict[str, dict[str, Any]] = {}
|
|
||||||
_balloons_lock = threading.Lock()
|
|
||||||
|
|
||||||
# UDP listener socket reference (so /stop can close it)
|
|
||||||
_udp_socket: socket.socket | None = None
|
|
||||||
|
|
||||||
# Common installation paths for radiosonde_auto_rx
|
|
||||||
AUTO_RX_PATHS = [
|
|
||||||
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
|
|
||||||
'/usr/local/bin/radiosonde_auto_rx',
|
|
||||||
'/opt/auto_rx/auto_rx.py',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def find_auto_rx() -> str | None:
|
|
||||||
"""Find radiosonde_auto_rx script/binary."""
|
|
||||||
# Check PATH first
|
|
||||||
path = shutil.which('radiosonde_auto_rx')
|
|
||||||
if path:
|
|
||||||
return path
|
|
||||||
# Check common locations
|
|
||||||
for p in AUTO_RX_PATHS:
|
|
||||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
||||||
return p
|
|
||||||
# Check for Python script (not executable but runnable)
|
|
||||||
for p in AUTO_RX_PATHS:
|
|
||||||
if os.path.isfile(p):
|
|
||||||
return p
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def generate_station_cfg(
|
|
||||||
freq_min: float = 400.0,
|
|
||||||
freq_max: float = 406.0,
|
|
||||||
gain: float = 40.0,
|
|
||||||
device_index: int = 0,
|
|
||||||
ppm: int = 0,
|
|
||||||
bias_t: bool = False,
|
|
||||||
udp_port: int = RADIOSONDE_UDP_PORT,
|
|
||||||
latitude: float = 0.0,
|
|
||||||
longitude: float = 0.0,
|
|
||||||
station_alt: float = 0.0,
|
|
||||||
gpsd_enabled: bool = False,
|
|
||||||
) -> str:
|
|
||||||
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
|
||||||
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
|
||||||
log_dir = os.path.join(cfg_dir, 'logs')
|
|
||||||
os.makedirs(log_dir, exist_ok=True)
|
|
||||||
cfg_path = os.path.join(cfg_dir, 'station.cfg')
|
|
||||||
|
|
||||||
# Full station.cfg based on radiosonde_auto_rx v1.8+ example config.
|
|
||||||
# All sections and keys included to avoid missing-key crashes.
|
|
||||||
cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx
|
|
||||||
|
|
||||||
[sdr]
|
|
||||||
sdr_type = RTLSDR
|
|
||||||
sdr_quantity = 1
|
|
||||||
sdr_hostname = localhost
|
|
||||||
sdr_port = 5555
|
|
||||||
|
|
||||||
[sdr_1]
|
|
||||||
device_idx = {device_index}
|
|
||||||
ppm = {ppm}
|
|
||||||
gain = {gain}
|
|
||||||
bias = {str(bias_t)}
|
|
||||||
|
|
||||||
[search_params]
|
|
||||||
min_freq = {freq_min}
|
|
||||||
max_freq = {freq_max}
|
|
||||||
rx_timeout = 180
|
|
||||||
only_scan = []
|
|
||||||
never_scan = []
|
|
||||||
always_scan = []
|
|
||||||
always_decode = []
|
|
||||||
|
|
||||||
[location]
|
|
||||||
station_lat = {latitude}
|
|
||||||
station_lon = {longitude}
|
|
||||||
station_alt = {station_alt}
|
|
||||||
gpsd_enabled = {str(gpsd_enabled)}
|
|
||||||
gpsd_host = localhost
|
|
||||||
gpsd_port = 2947
|
|
||||||
|
|
||||||
[habitat]
|
|
||||||
uploader_callsign = INTERCEPT
|
|
||||||
upload_listener_position = False
|
|
||||||
uploader_antenna = unknown
|
|
||||||
|
|
||||||
[sondehub]
|
|
||||||
sondehub_enabled = False
|
|
||||||
sondehub_upload_rate = 15
|
|
||||||
sondehub_contact_email = none@none.com
|
|
||||||
|
|
||||||
[aprs]
|
|
||||||
aprs_enabled = False
|
|
||||||
aprs_user = N0CALL
|
|
||||||
aprs_pass = 00000
|
|
||||||
upload_rate = 30
|
|
||||||
aprs_server = radiosondy.info
|
|
||||||
aprs_port = 14580
|
|
||||||
station_beacon_enabled = False
|
|
||||||
station_beacon_rate = 30
|
|
||||||
station_beacon_comment = radiosonde_auto_rx
|
|
||||||
station_beacon_icon = /`
|
|
||||||
aprs_object_id = <id>
|
|
||||||
aprs_use_custom_object_id = False
|
|
||||||
aprs_custom_comment = <type> <freq>
|
|
||||||
|
|
||||||
[oziplotter]
|
|
||||||
ozi_enabled = False
|
|
||||||
ozi_update_rate = 5
|
|
||||||
ozi_host = 127.0.0.1
|
|
||||||
ozi_port = 8942
|
|
||||||
payload_summary_enabled = True
|
|
||||||
payload_summary_host = 127.0.0.1
|
|
||||||
payload_summary_port = {udp_port}
|
|
||||||
|
|
||||||
[email]
|
|
||||||
email_enabled = False
|
|
||||||
launch_notifications = True
|
|
||||||
landing_notifications = True
|
|
||||||
encrypted_sonde_notifications = True
|
|
||||||
landing_range_threshold = 30
|
|
||||||
landing_altitude_threshold = 1000
|
|
||||||
error_notifications = False
|
|
||||||
smtp_server = localhost
|
|
||||||
smtp_port = 25
|
|
||||||
smtp_authentication = None
|
|
||||||
smtp_login = None
|
|
||||||
smtp_password = None
|
|
||||||
from = sonde@localhost
|
|
||||||
to = none@none.com
|
|
||||||
subject = Sonde launch detected
|
|
||||||
|
|
||||||
[rotator]
|
|
||||||
rotator_enabled = False
|
|
||||||
update_rate = 30
|
|
||||||
rotation_threshold = 5.0
|
|
||||||
rotator_hostname = 127.0.0.1
|
|
||||||
rotator_port = 4533
|
|
||||||
rotator_homing_enabled = False
|
|
||||||
rotator_homing_delay = 10
|
|
||||||
rotator_home_azimuth = 0.0
|
|
||||||
rotator_home_elevation = 0.0
|
|
||||||
azimuth_only = False
|
|
||||||
|
|
||||||
[logging]
|
|
||||||
per_sonde_log = True
|
|
||||||
save_system_log = False
|
|
||||||
enable_debug_logging = False
|
|
||||||
save_cal_data = False
|
|
||||||
|
|
||||||
[web]
|
|
||||||
web_host = 127.0.0.1
|
|
||||||
web_port = 0
|
|
||||||
archive_age = 120
|
|
||||||
web_control = False
|
|
||||||
web_password = none
|
|
||||||
kml_refresh_rate = 10
|
|
||||||
|
|
||||||
[debugging]
|
|
||||||
save_detection_audio = False
|
|
||||||
save_decode_audio = False
|
|
||||||
save_decode_iq = False
|
|
||||||
save_raw_hex = False
|
|
||||||
|
|
||||||
[advanced]
|
|
||||||
search_step = 800
|
|
||||||
snr_threshold = 10
|
|
||||||
max_peaks = 10
|
|
||||||
min_distance = 1000
|
|
||||||
scan_dwell_time = 20
|
|
||||||
detect_dwell_time = 5
|
|
||||||
scan_delay = 10
|
|
||||||
quantization = 10000
|
|
||||||
decoder_spacing_limit = 15000
|
|
||||||
temporary_block_time = 120
|
|
||||||
max_async_scan_workers = 4
|
|
||||||
synchronous_upload = True
|
|
||||||
payload_id_valid = 3
|
|
||||||
sdr_fm_path = rtl_fm
|
|
||||||
sdr_power_path = rtl_power
|
|
||||||
ss_iq_path = ./ss_iq
|
|
||||||
ss_power_path = ./ss_power
|
|
||||||
|
|
||||||
[filtering]
|
|
||||||
max_altitude = 50000
|
|
||||||
max_radius_km = 1000
|
|
||||||
min_radius_km = 0
|
|
||||||
radius_temporary_block = False
|
|
||||||
sonde_time_threshold = 3
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(cfg_path, 'w') as f:
|
|
||||||
f.write(cfg)
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(f"Cannot write station.cfg to {cfg_path}: {e}")
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Cannot write radiosonde config to {cfg_path}: {e}. "
|
|
||||||
f"Fix permissions with: sudo chown -R $(whoami) {cfg_dir}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
# When running as root via sudo, fix ownership so next non-root run
|
|
||||||
# can still read/write these files.
|
|
||||||
_fix_data_ownership(cfg_dir)
|
|
||||||
|
|
||||||
logger.info(f"Generated station.cfg at {cfg_path}")
|
|
||||||
return cfg_path
|
|
||||||
|
|
||||||
|
|
||||||
def _fix_data_ownership(path: str) -> None:
|
|
||||||
"""Recursively chown a path to the real (non-root) user when running via sudo."""
|
|
||||||
uid = os.environ.get('INTERCEPT_SUDO_UID')
|
|
||||||
gid = os.environ.get('INTERCEPT_SUDO_GID')
|
|
||||||
if uid is None or gid is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
uid_int, gid_int = int(uid), int(gid)
|
|
||||||
for dirpath, _dirnames, filenames in os.walk(path):
|
|
||||||
os.chown(dirpath, uid_int, gid_int)
|
|
||||||
for fname in filenames:
|
|
||||||
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Could not fix ownership of {path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_radiosonde_udp(udp_port: int) -> None:
|
|
||||||
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
|
|
||||||
global radiosonde_running, _udp_socket
|
|
||||||
|
|
||||||
logger.info(f"Radiosonde UDP listener started on port {udp_port}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
sock.bind(('0.0.0.0', udp_port))
|
|
||||||
sock.settimeout(2.0)
|
|
||||||
_udp_socket = sock
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(f"Failed to bind UDP port {udp_port}: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
while radiosonde_running:
|
|
||||||
try:
|
|
||||||
data, _addr = sock.recvfrom(4096)
|
|
||||||
except socket.timeout:
|
|
||||||
# Clean up stale balloons
|
|
||||||
_cleanup_stale_balloons()
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = json.loads(data.decode('utf-8', errors='ignore'))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
balloon = _process_telemetry(msg)
|
|
||||||
if balloon:
|
|
||||||
serial = balloon.get('id', '')
|
|
||||||
if serial:
|
|
||||||
with _balloons_lock:
|
|
||||||
radiosonde_balloons[serial] = balloon
|
|
||||||
with contextlib.suppress(queue.Full):
|
|
||||||
app_module.radiosonde_queue.put_nowait({
|
|
||||||
'type': 'balloon',
|
|
||||||
**balloon,
|
|
||||||
})
|
|
||||||
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
sock.close()
|
|
||||||
_udp_socket = None
|
|
||||||
logger.info("Radiosonde UDP listener stopped")
|
|
||||||
|
|
||||||
|
|
||||||
def _process_telemetry(msg: dict) -> dict | None:
|
|
||||||
"""Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet."""
|
|
||||||
# auto_rx broadcasts packets with a 'type' field
|
|
||||||
# Telemetry packets have type 'payload_summary' or individual sonde data
|
|
||||||
serial = msg.get('id') or msg.get('serial')
|
|
||||||
if not serial:
|
|
||||||
return None
|
|
||||||
|
|
||||||
balloon: dict[str, Any] = {'id': str(serial)}
|
|
||||||
|
|
||||||
# Sonde type (RS41, RS92, DFM, M10, etc.) — prefer subtype if available
|
|
||||||
if 'subtype' in msg:
|
|
||||||
balloon['sonde_type'] = msg['subtype']
|
|
||||||
elif 'type' in msg:
|
|
||||||
balloon['sonde_type'] = msg['type']
|
|
||||||
|
|
||||||
# Timestamp
|
|
||||||
if 'datetime' in msg:
|
|
||||||
balloon['datetime'] = msg['datetime']
|
|
||||||
|
|
||||||
# Position
|
|
||||||
for key in ('lat', 'latitude'):
|
|
||||||
if key in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['lat'] = float(msg[key])
|
|
||||||
break
|
|
||||||
for key in ('lon', 'longitude'):
|
|
||||||
if key in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['lon'] = float(msg[key])
|
|
||||||
break
|
|
||||||
|
|
||||||
# Altitude (metres)
|
|
||||||
if 'alt' in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['alt'] = float(msg['alt'])
|
|
||||||
|
|
||||||
# Meteorological data
|
|
||||||
for field in ('temp', 'humidity', 'pressure'):
|
|
||||||
if field in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon[field] = float(msg[field])
|
|
||||||
|
|
||||||
# Velocity
|
|
||||||
if 'vel_h' in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['vel_h'] = float(msg['vel_h'])
|
|
||||||
if 'vel_v' in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['vel_v'] = float(msg['vel_v'])
|
|
||||||
if 'heading' in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['heading'] = float(msg['heading'])
|
|
||||||
|
|
||||||
# GPS satellites
|
|
||||||
if 'sats' in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['sats'] = int(msg['sats'])
|
|
||||||
|
|
||||||
# Battery voltage
|
|
||||||
if 'batt' in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['batt'] = float(msg['batt'])
|
|
||||||
|
|
||||||
# Frequency
|
|
||||||
if 'freq' in msg:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
balloon['freq'] = float(msg['freq'])
|
|
||||||
|
|
||||||
balloon['last_seen'] = time.time()
|
|
||||||
return balloon
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_stale_balloons() -> None:
|
|
||||||
"""Remove balloons not seen within the retention window."""
|
|
||||||
now = time.time()
|
|
||||||
with _balloons_lock:
|
|
||||||
stale = [
|
|
||||||
k for k, v in radiosonde_balloons.items()
|
|
||||||
if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS
|
|
||||||
]
|
|
||||||
for k in stale:
|
|
||||||
del radiosonde_balloons[k]
|
|
||||||
|
|
||||||
|
|
||||||
@radiosonde_bp.route('/tools')
|
|
||||||
def check_tools():
|
|
||||||
"""Check for radiosonde decoding tools and hardware."""
|
|
||||||
auto_rx_path = find_auto_rx()
|
|
||||||
devices = SDRFactory.detect_devices()
|
|
||||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'auto_rx': auto_rx_path is not None,
|
|
||||||
'auto_rx_path': auto_rx_path,
|
|
||||||
'has_rtlsdr': has_rtlsdr,
|
|
||||||
'device_count': len(devices),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@radiosonde_bp.route('/status')
|
|
||||||
def radiosonde_status():
|
|
||||||
"""Get radiosonde tracking status."""
|
|
||||||
process_running = False
|
|
||||||
if app_module.radiosonde_process:
|
|
||||||
process_running = app_module.radiosonde_process.poll() is None
|
|
||||||
|
|
||||||
with _balloons_lock:
|
|
||||||
balloon_count = len(radiosonde_balloons)
|
|
||||||
balloons_snapshot = dict(radiosonde_balloons)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'tracking_active': radiosonde_running,
|
|
||||||
'active_device': radiosonde_active_device,
|
|
||||||
'balloon_count': balloon_count,
|
|
||||||
'balloons': balloons_snapshot,
|
|
||||||
'queue_size': app_module.radiosonde_queue.qsize(),
|
|
||||||
'auto_rx_path': find_auto_rx(),
|
|
||||||
'process_running': process_running,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@radiosonde_bp.route('/start', methods=['POST'])
|
|
||||||
def start_radiosonde():
|
|
||||||
"""Start radiosonde tracking."""
|
|
||||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type
|
|
||||||
|
|
||||||
with app_module.radiosonde_lock:
|
|
||||||
if radiosonde_running:
|
|
||||||
return api_error('Radiosonde tracking already active', 409)
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
# Validate inputs
|
|
||||||
try:
|
|
||||||
gain = float(validate_gain(data.get('gain', '40')))
|
|
||||||
device = validate_device_index(data.get('device', '0'))
|
|
||||||
except ValueError as e:
|
|
||||||
return api_error(str(e), 400)
|
|
||||||
|
|
||||||
freq_min = data.get('freq_min', 400.0)
|
|
||||||
freq_max = data.get('freq_max', 406.0)
|
|
||||||
try:
|
|
||||||
freq_min = float(freq_min)
|
|
||||||
freq_max = float(freq_max)
|
|
||||||
if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0):
|
|
||||||
raise ValueError("Frequency out of range")
|
|
||||||
if freq_min >= freq_max:
|
|
||||||
raise ValueError("Min frequency must be less than max")
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
return api_error(f'Invalid frequency range: {e}', 400)
|
|
||||||
|
|
||||||
bias_t = data.get('bias_t', False)
|
|
||||||
ppm = int(data.get('ppm', 0))
|
|
||||||
|
|
||||||
# Validate optional location
|
|
||||||
latitude = 0.0
|
|
||||||
longitude = 0.0
|
|
||||||
if data.get('latitude') is not None and data.get('longitude') is not None:
|
|
||||||
try:
|
|
||||||
latitude = validate_latitude(data['latitude'])
|
|
||||||
longitude = validate_longitude(data['longitude'])
|
|
||||||
except ValueError:
|
|
||||||
latitude = 0.0
|
|
||||||
longitude = 0.0
|
|
||||||
|
|
||||||
# Check if gpsd is available for live position updates
|
|
||||||
gpsd_enabled = is_gpsd_running()
|
|
||||||
|
|
||||||
# Find auto_rx
|
|
||||||
auto_rx_path = find_auto_rx()
|
|
||||||
if not auto_rx_path:
|
|
||||||
return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
|
|
||||||
|
|
||||||
# Get SDR type
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
|
|
||||||
# Kill any existing process
|
|
||||||
if app_module.radiosonde_process:
|
|
||||||
try:
|
|
||||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
||||||
os.killpg(pgid, 15)
|
|
||||||
app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
|
||||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
|
||||||
try:
|
|
||||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
||||||
os.killpg(pgid, 9)
|
|
||||||
except (ProcessLookupError, OSError):
|
|
||||||
pass
|
|
||||||
app_module.radiosonde_process = None
|
|
||||||
logger.info("Killed existing radiosonde process")
|
|
||||||
|
|
||||||
# Claim SDR device
|
|
||||||
device_int = int(device)
|
|
||||||
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
|
||||||
if error:
|
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
|
||||||
|
|
||||||
# Generate config
|
|
||||||
try:
|
|
||||||
cfg_path = generate_station_cfg(
|
|
||||||
freq_min=freq_min,
|
|
||||||
freq_max=freq_max,
|
|
||||||
gain=gain,
|
|
||||||
device_index=device_int,
|
|
||||||
ppm=ppm,
|
|
||||||
bias_t=bias_t,
|
|
||||||
latitude=latitude,
|
|
||||||
longitude=longitude,
|
|
||||||
gpsd_enabled=gpsd_enabled,
|
|
||||||
)
|
|
||||||
except (OSError, RuntimeError) as e:
|
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
||||||
logger.error(f"Failed to generate radiosonde config: {e}")
|
|
||||||
return api_error(str(e), 500)
|
|
||||||
|
|
||||||
# Build command - auto_rx -c expects the path to station.cfg
|
|
||||||
cfg_abs = os.path.abspath(cfg_path)
|
|
||||||
if auto_rx_path.endswith('.py'):
|
|
||||||
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
|
|
||||||
else:
|
|
||||||
cmd = [auto_rx_path, '-c', cfg_abs]
|
|
||||||
|
|
||||||
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
|
||||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
|
||||||
|
|
||||||
# Quick dependency check before launching the full process
|
|
||||||
if auto_rx_path.endswith('.py'):
|
|
||||||
dep_check = subprocess.run(
|
|
||||||
[sys.executable, '-c', 'import autorx.scan'],
|
|
||||||
cwd=auto_rx_dir,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if dep_check.returncode != 0:
|
|
||||||
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
|
||||||
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
||||||
return api_error(
|
|
||||||
'radiosonde_auto_rx dependencies not satisfied. '
|
|
||||||
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
|
|
||||||
500,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
|
||||||
app_module.radiosonde_process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
start_new_session=True,
|
|
||||||
cwd=auto_rx_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait briefly for process to start
|
|
||||||
time.sleep(2.0)
|
|
||||||
|
|
||||||
if app_module.radiosonde_process.poll() is not None:
|
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
||||||
stderr_output = ''
|
|
||||||
if app_module.radiosonde_process.stderr:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
|
||||||
'utf-8', errors='ignore'
|
|
||||||
).strip()
|
|
||||||
if stderr_output:
|
|
||||||
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
|
|
||||||
if stderr_output and (
|
|
||||||
'ImportError' in stderr_output
|
|
||||||
or 'ModuleNotFoundError' in stderr_output
|
|
||||||
):
|
|
||||||
error_msg = (
|
|
||||||
'radiosonde_auto_rx failed to start due to missing Python '
|
|
||||||
'dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx.'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
error_msg = (
|
|
||||||
'radiosonde_auto_rx failed to start. '
|
|
||||||
'Check SDR device connection.'
|
|
||||||
)
|
|
||||||
if stderr_output:
|
|
||||||
error_msg += f' Error: {stderr_output[:500]}'
|
|
||||||
return api_error(error_msg, 500)
|
|
||||||
|
|
||||||
radiosonde_running = True
|
|
||||||
radiosonde_active_device = device_int
|
|
||||||
radiosonde_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Clear stale data
|
|
||||||
with _balloons_lock:
|
|
||||||
radiosonde_balloons.clear()
|
|
||||||
|
|
||||||
# Start UDP listener thread
|
|
||||||
udp_thread = threading.Thread(
|
|
||||||
target=parse_radiosonde_udp,
|
|
||||||
args=(RADIOSONDE_UDP_PORT,),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
udp_thread.start()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'message': 'Radiosonde tracking started',
|
|
||||||
'device': device,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
||||||
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
|
|
||||||
return api_error(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@radiosonde_bp.route('/stop', methods=['POST'])
|
|
||||||
def stop_radiosonde():
|
|
||||||
"""Stop radiosonde tracking."""
|
|
||||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket
|
|
||||||
|
|
||||||
with app_module.radiosonde_lock:
|
|
||||||
if app_module.radiosonde_process:
|
|
||||||
try:
|
|
||||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
||||||
os.killpg(pgid, 15)
|
|
||||||
app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT)
|
|
||||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
|
||||||
try:
|
|
||||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
||||||
os.killpg(pgid, 9)
|
|
||||||
except (ProcessLookupError, OSError):
|
|
||||||
pass
|
|
||||||
app_module.radiosonde_process = None
|
|
||||||
logger.info("Radiosonde process stopped")
|
|
||||||
|
|
||||||
# Close UDP socket to unblock listener thread
|
|
||||||
if _udp_socket:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
_udp_socket.close()
|
|
||||||
_udp_socket = None
|
|
||||||
|
|
||||||
# Release SDR device
|
|
||||||
if radiosonde_active_device is not None:
|
|
||||||
app_module.release_sdr_device(
|
|
||||||
radiosonde_active_device,
|
|
||||||
radiosonde_active_sdr_type or 'rtlsdr',
|
|
||||||
)
|
|
||||||
|
|
||||||
radiosonde_running = False
|
|
||||||
radiosonde_active_device = None
|
|
||||||
radiosonde_active_sdr_type = None
|
|
||||||
|
|
||||||
with _balloons_lock:
|
|
||||||
radiosonde_balloons.clear()
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@radiosonde_bp.route('/stream')
|
|
||||||
def stream_radiosonde():
|
|
||||||
"""SSE stream for radiosonde telemetry."""
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=app_module.radiosonde_queue,
|
|
||||||
channel_key='radiosonde',
|
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
)
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@radiosonde_bp.route('/balloons')
|
|
||||||
def get_balloons():
|
|
||||||
"""Get current balloon data."""
|
|
||||||
with _balloons_lock:
|
|
||||||
return api_success(data={
|
|
||||||
'count': len(radiosonde_balloons),
|
|
||||||
'balloons': dict(radiosonde_balloons),
|
|
||||||
})
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""Session recording API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from flask import Blueprint, request, send_file
|
|
||||||
|
|
||||||
from utils.recording import RECORDING_ROOT, get_recording_manager
|
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
|
|
||||||
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/start', methods=['POST'])
|
|
||||||
def start_recording():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
mode = (data.get('mode') or '').strip()
|
|
||||||
if not mode:
|
|
||||||
return api_error('mode is required', 400)
|
|
||||||
|
|
||||||
label = data.get('label')
|
|
||||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
|
||||||
|
|
||||||
manager = get_recording_manager()
|
|
||||||
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
|
||||||
|
|
||||||
return api_success(data={'session': {
|
|
||||||
'id': session.id,
|
|
||||||
'mode': session.mode,
|
|
||||||
'label': session.label,
|
|
||||||
'started_at': session.started_at.isoformat(),
|
|
||||||
'file_path': str(session.file_path),
|
|
||||||
}})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/stop', methods=['POST'])
|
|
||||||
def stop_recording():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
mode = data.get('mode')
|
|
||||||
session_id = data.get('id')
|
|
||||||
|
|
||||||
manager = get_recording_manager()
|
|
||||||
session = manager.stop_recording(mode=mode, session_id=session_id)
|
|
||||||
if not session:
|
|
||||||
return api_error('No active recording found', 404)
|
|
||||||
|
|
||||||
return api_success(data={'session': {
|
|
||||||
'id': session.id,
|
|
||||||
'mode': session.mode,
|
|
||||||
'label': session.label,
|
|
||||||
'started_at': session.started_at.isoformat(),
|
|
||||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
|
||||||
'event_count': session.event_count,
|
|
||||||
'size_bytes': session.size_bytes,
|
|
||||||
'file_path': str(session.file_path),
|
|
||||||
}})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('', methods=['GET'])
|
|
||||||
def list_recordings():
|
|
||||||
manager = get_recording_manager()
|
|
||||||
limit = request.args.get('limit', default=50, type=int)
|
|
||||||
return api_success(data={
|
|
||||||
'recordings': manager.list_recordings(limit=limit),
|
|
||||||
'active': manager.get_active(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/<session_id>', methods=['GET'])
|
|
||||||
def get_recording(session_id: str):
|
|
||||||
manager = get_recording_manager()
|
|
||||||
rec = manager.get_recording(session_id)
|
|
||||||
if not rec:
|
|
||||||
return api_error('Recording not found', 404)
|
|
||||||
return api_success(data={'recording': rec})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
|
||||||
def download_recording(session_id: str):
|
|
||||||
manager = get_recording_manager()
|
|
||||||
rec = manager.get_recording(session_id)
|
|
||||||
if not rec:
|
|
||||||
return api_error('Recording not found', 404)
|
|
||||||
|
|
||||||
file_path = Path(rec['file_path'])
|
|
||||||
try:
|
|
||||||
resolved_root = RECORDING_ROOT.resolve()
|
|
||||||
resolved_file = file_path.resolve()
|
|
||||||
if resolved_root not in resolved_file.parents:
|
|
||||||
return api_error('Invalid recording path', 400)
|
|
||||||
except Exception:
|
|
||||||
return api_error('Invalid recording path', 400)
|
|
||||||
|
|
||||||
if not file_path.exists():
|
|
||||||
return api_error('Recording file missing', 404)
|
|
||||||
|
|
||||||
return send_file(
|
|
||||||
file_path,
|
|
||||||
mimetype='application/x-ndjson',
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=file_path.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/<session_id>/events', methods=['GET'])
|
|
||||||
def get_recording_events(session_id: str):
|
|
||||||
"""Return parsed events from a recording for in-app replay."""
|
|
||||||
manager = get_recording_manager()
|
|
||||||
rec = manager.get_recording(session_id)
|
|
||||||
if not rec:
|
|
||||||
return api_error('Recording not found', 404)
|
|
||||||
|
|
||||||
file_path = Path(rec['file_path'])
|
|
||||||
try:
|
|
||||||
resolved_root = RECORDING_ROOT.resolve()
|
|
||||||
resolved_file = file_path.resolve()
|
|
||||||
if resolved_root not in resolved_file.parents:
|
|
||||||
return api_error('Invalid recording path', 400)
|
|
||||||
except Exception:
|
|
||||||
return api_error('Invalid recording path', 400)
|
|
||||||
|
|
||||||
if not file_path.exists():
|
|
||||||
return api_error('Recording file missing', 404)
|
|
||||||
|
|
||||||
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
|
||||||
offset = max(0, request.args.get('offset', default=0, type=int))
|
|
||||||
|
|
||||||
events: list[dict] = []
|
|
||||||
seen = 0
|
|
||||||
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
|
|
||||||
for idx, line in enumerate(fh):
|
|
||||||
if idx < offset:
|
|
||||||
continue
|
|
||||||
if seen >= limit:
|
|
||||||
break
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
events.append(json.loads(line))
|
|
||||||
seen += 1
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return api_success(data={
|
|
||||||
'recording': {
|
|
||||||
'id': rec['id'],
|
|
||||||
'mode': rec['mode'],
|
|
||||||
'started_at': rec['started_at'],
|
|
||||||
'stopped_at': rec['stopped_at'],
|
|
||||||
'event_count': rec['event_count'],
|
|
||||||
},
|
|
||||||
'offset': offset,
|
|
||||||
'limit': limit,
|
|
||||||
'returned': len(events),
|
|
||||||
'events': events,
|
|
||||||
})
|
|
||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
import json
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.process import register_process, unregister_process
|
from utils.validation import (
|
||||||
from utils.responses import api_error
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import sse_stream_fanout
|
)
|
||||||
from utils.validation import validate_device_index, validate_frequency, validate_gain, validate_ppm
|
from utils.sse import format_sse
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
|
||||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ rtl_tcp_lock = threading.Lock()
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
rtlamr_active_device: int | None = None
|
rtlamr_active_device: int | None = None
|
||||||
rtlamr_active_sdr_type: str = 'rtlsdr'
|
|
||||||
|
|
||||||
|
|
||||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
@@ -62,14 +61,16 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
# Ensure rtlamr process is terminated
|
# Ensure rtlamr process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
# Kill companion rtl_tcp process
|
# Kill companion rtl_tcp process
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
@@ -78,8 +79,10 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
rtl_tcp_process.terminate()
|
rtl_tcp_process.terminate()
|
||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
rtl_tcp_process.kill()
|
rtl_tcp_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
unregister_process(rtl_tcp_process)
|
unregister_process(rtl_tcp_process)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
@@ -87,23 +90,19 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.rtlamr_process = None
|
app_module.rtlamr_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||||
def start_rtlamr() -> Response:
|
def start_rtlamr() -> Response:
|
||||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
return api_error('RTLAMR already running', 409)
|
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
|
|
||||||
if sdr_type_str != 'rtlsdr':
|
|
||||||
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
try:
|
try:
|
||||||
@@ -112,16 +111,19 @@ def start_rtlamr() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||||
if error:
|
if error:
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
rtlamr_active_device = device_int
|
rtlamr_active_device = device_int
|
||||||
rtlamr_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.rtlamr_queue.empty():
|
while not app_module.rtlamr_queue.empty():
|
||||||
@@ -135,8 +137,6 @@ def start_rtlamr() -> Response:
|
|||||||
output_format = data.get('format', 'json')
|
output_format = data.get('format', 'json')
|
||||||
|
|
||||||
# Start rtl_tcp first
|
# Start rtl_tcp first
|
||||||
rtl_tcp_just_started = False
|
|
||||||
rtl_tcp_cmd_str = ''
|
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if not rtl_tcp_process:
|
if not rtl_tcp_process:
|
||||||
logger.info("Starting rtl_tcp server...")
|
logger.info("Starting rtl_tcp server...")
|
||||||
@@ -161,21 +161,19 @@ def start_rtlamr() -> Response:
|
|||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
register_process(rtl_tcp_process)
|
register_process(rtl_tcp_process)
|
||||||
rtl_tcp_just_started = True
|
|
||||||
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
|
# Wait a moment for rtl_tcp to start
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
|
||||||
|
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||||
# Release SDR device on rtl_tcp failure
|
# Release SDR device on rtl_tcp failure
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return api_error(f'Failed to start rtl_tcp: {e}', 500)
|
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||||
|
|
||||||
# Wait for rtl_tcp to start outside lock
|
|
||||||
if rtl_tcp_just_started:
|
|
||||||
time.sleep(3)
|
|
||||||
logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}")
|
|
||||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'})
|
|
||||||
|
|
||||||
# Build rtlamr command
|
# Build rtlamr command
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -239,9 +237,9 @@ def start_rtlamr() -> Response:
|
|||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr')
|
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If rtlamr fails, clean up rtl_tcp and release device
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
@@ -250,47 +248,38 @@ def start_rtlamr() -> Response:
|
|||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return api_error(str(e))
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||||
def stop_rtlamr() -> Response:
|
def stop_rtlamr() -> Response:
|
||||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
# Grab process refs inside locks, clear state, then terminate outside
|
|
||||||
rtlamr_proc = None
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
rtlamr_proc = app_module.rtlamr_process
|
app_module.rtlamr_process.terminate()
|
||||||
|
try:
|
||||||
|
app_module.rtlamr_process.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app_module.rtlamr_process.kill()
|
||||||
app_module.rtlamr_process = None
|
app_module.rtlamr_process = None
|
||||||
|
|
||||||
if rtlamr_proc:
|
|
||||||
rtlamr_proc.terminate()
|
|
||||||
try:
|
|
||||||
rtlamr_proc.wait(timeout=2)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
rtlamr_proc.kill()
|
|
||||||
|
|
||||||
# Also stop rtl_tcp
|
# Also stop rtl_tcp
|
||||||
tcp_proc = None
|
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if rtl_tcp_process:
|
if rtl_tcp_process:
|
||||||
tcp_proc = rtl_tcp_process
|
rtl_tcp_process.terminate()
|
||||||
rtl_tcp_process = None
|
|
||||||
|
|
||||||
if tcp_proc:
|
|
||||||
tcp_proc.terminate()
|
|
||||||
try:
|
try:
|
||||||
tcp_proc.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
tcp_proc.kill()
|
rtl_tcp_process.kill()
|
||||||
|
rtl_tcp_process = None
|
||||||
logger.info("rtl_tcp stopped")
|
logger.info("rtl_tcp stopped")
|
||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
@@ -298,19 +287,22 @@ def stop_rtlamr() -> Response:
|
|||||||
|
|
||||||
@rtlamr_bp.route('/stream_rtlamr')
|
@rtlamr_bp.route('/stream_rtlamr')
|
||||||
def stream_rtlamr() -> Response:
|
def stream_rtlamr() -> Response:
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('rtlamr', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=app_module.rtlamr_queue,
|
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||||
channel_key='rtlamr',
|
last_keepalive = time.time()
|
||||||
timeout=1.0,
|
yield format_sse(msg)
|
||||||
keepalive_interval=30.0,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= keepalive_interval:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -2,39 +2,25 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Blueprint, jsonify, render_template, request
|
|
||||||
|
from flask import Blueprint, jsonify, request, render_template, Response
|
||||||
|
|
||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
from utils.database import (
|
|
||||||
add_tracked_satellite,
|
|
||||||
bulk_add_tracked_satellites,
|
|
||||||
get_tracked_satellites,
|
|
||||||
remove_tracked_satellite,
|
|
||||||
update_tracked_satellite,
|
|
||||||
)
|
|
||||||
from utils.logging import satellite_logger as logger
|
from utils.logging import satellite_logger as logger
|
||||||
from utils.responses import api_error
|
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||||
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
|
|
||||||
|
|
||||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||||
|
|
||||||
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
|
|
||||||
_cached_timescale = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_timescale():
|
|
||||||
global _cached_timescale
|
|
||||||
if _cached_timescale is None:
|
|
||||||
from skyfield.api import load
|
|
||||||
_cached_timescale = load.timescale()
|
|
||||||
return _cached_timescale
|
|
||||||
|
|
||||||
# Maximum response size for external requests (1MB)
|
# Maximum response size for external requests (1MB)
|
||||||
MAX_RESPONSE_SIZE = 1024 * 1024
|
MAX_RESPONSE_SIZE = 1024 * 1024
|
||||||
|
|
||||||
@@ -45,44 +31,7 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
|
||||||
def _load_db_satellites_into_cache():
|
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||||
"""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: float | None = None, observer_lon: float | None = None) -> dict | None:
|
|
||||||
"""
|
"""
|
||||||
Fetch real-time ISS position from external APIs.
|
Fetch real-time ISS position from external APIs.
|
||||||
|
|
||||||
@@ -173,11 +122,9 @@ def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float |
|
|||||||
@satellite_bp.route('/dashboard')
|
@satellite_bp.route('/dashboard')
|
||||||
def satellite_dashboard():
|
def satellite_dashboard():
|
||||||
"""Popout satellite tracking dashboard."""
|
"""Popout satellite tracking dashboard."""
|
||||||
embedded = request.args.get('embedded', 'false') == 'true'
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'satellite_dashboard.html',
|
'satellite_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
embedded=embedded,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -185,8 +132,8 @@ def satellite_dashboard():
|
|||||||
def predict_passes():
|
def predict_passes():
|
||||||
"""Calculate satellite passes using skyfield."""
|
"""Calculate satellite passes using skyfield."""
|
||||||
try:
|
try:
|
||||||
|
from skyfield.api import load, wgs84, EarthSatellite
|
||||||
from skyfield.almanac import find_discrete
|
from skyfield.almanac import find_discrete
|
||||||
from skyfield.api import EarthSatellite, wgs84
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -202,15 +149,19 @@ def predict_passes():
|
|||||||
hours = validate_hours(data.get('hours', 24))
|
hours = validate_hours(data.get('hours', 24))
|
||||||
min_el = validate_elevation(data.get('minEl', 10))
|
min_el = validate_elevation(data.get('minEl', 10))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
|
25338: 'NOAA-15',
|
||||||
|
28654: 'NOAA-18',
|
||||||
|
33591: 'NOAA-19',
|
||||||
|
43013: 'NOAA-20',
|
||||||
40069: 'METEOR-M2',
|
40069: 'METEOR-M2',
|
||||||
57166: 'METEOR-M2-3'
|
57166: 'METEOR-M2-3'
|
||||||
}
|
}
|
||||||
|
|
||||||
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
||||||
satellites = []
|
satellites = []
|
||||||
for sat in sat_input:
|
for sat in sat_input:
|
||||||
if isinstance(sat, int) and sat in norad_to_name:
|
if isinstance(sat, int) and sat in norad_to_name:
|
||||||
@@ -221,12 +172,16 @@ def predict_passes():
|
|||||||
passes = []
|
passes = []
|
||||||
colors = {
|
colors = {
|
||||||
'ISS': '#00ffff',
|
'ISS': '#00ffff',
|
||||||
|
'NOAA-15': '#00ff00',
|
||||||
|
'NOAA-18': '#ff6600',
|
||||||
|
'NOAA-19': '#ff3366',
|
||||||
|
'NOAA-20': '#00ffaa',
|
||||||
'METEOR-M2': '#9370DB',
|
'METEOR-M2': '#9370DB',
|
||||||
'METEOR-M2-3': '#ff00ff'
|
'METEOR-M2-3': '#ff00ff'
|
||||||
}
|
}
|
||||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||||
|
|
||||||
ts = _get_timescale()
|
ts = load.timescale()
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
|
||||||
t0 = ts.now()
|
t0 = ts.now()
|
||||||
@@ -339,9 +294,9 @@ def predict_passes():
|
|||||||
def get_satellite_position():
|
def get_satellite_position():
|
||||||
"""Get real-time positions of satellites."""
|
"""Get real-time positions of satellites."""
|
||||||
try:
|
try:
|
||||||
from skyfield.api import EarthSatellite, wgs84
|
from skyfield.api import load, wgs84, EarthSatellite
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return api_error('skyfield not installed', 503)
|
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -350,13 +305,17 @@ def get_satellite_position():
|
|||||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
sat_input = data.get('satellites', [])
|
sat_input = data.get('satellites', [])
|
||||||
include_track = bool(data.get('includeTrack', True))
|
include_track = bool(data.get('includeTrack', True))
|
||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
|
25338: 'NOAA-15',
|
||||||
|
28654: 'NOAA-18',
|
||||||
|
33591: 'NOAA-19',
|
||||||
|
43013: 'NOAA-20',
|
||||||
40069: 'METEOR-M2',
|
40069: 'METEOR-M2',
|
||||||
57166: 'METEOR-M2-3'
|
57166: 'METEOR-M2-3'
|
||||||
}
|
}
|
||||||
@@ -368,7 +327,7 @@ def get_satellite_position():
|
|||||||
else:
|
else:
|
||||||
satellites.append(sat)
|
satellites.append(sat)
|
||||||
|
|
||||||
ts = _get_timescale()
|
ts = load.timescale()
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
now = ts.now()
|
now = ts.now()
|
||||||
now_dt = now.utc_datetime()
|
now_dt = now.utc_datetime()
|
||||||
@@ -475,8 +434,7 @@ def refresh_tle_data() -> list:
|
|||||||
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
||||||
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
||||||
'METEOR-M 2': 'METEOR-M2',
|
'METEOR-M 2': 'METEOR-M2',
|
||||||
'METEOR-M2 3': 'METEOR-M2-3',
|
'METEOR-M2 3': 'METEOR-M2-3'
|
||||||
'METEOR-M2 4': 'METEOR-M2-4'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updated = []
|
updated = []
|
||||||
@@ -523,8 +481,7 @@ def update_tle():
|
|||||||
'updated': updated
|
'updated': updated
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating TLE data: {e}")
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
return api_error('TLE update failed')
|
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/celestrak/<category>')
|
@satellite_bp.route('/celestrak/<category>')
|
||||||
@@ -538,7 +495,7 @@ def fetch_celestrak(category):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if category not in valid_categories:
|
if category not in valid_categories:
|
||||||
return api_error(f'Invalid category. Valid: {valid_categories}')
|
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
||||||
@@ -578,104 +535,4 @@ def fetch_celestrak(category):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
return api_error('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.get_json(silent=True)
|
|
||||||
if not data:
|
|
||||||
return api_error('No data provided', 400)
|
|
||||||
|
|
||||||
# Accept a single satellite dict or a list
|
|
||||||
sat_list = data if isinstance(data, list) else [data]
|
|
||||||
|
|
||||||
normalized: list[dict] = []
|
|
||||||
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)
|
|
||||||
|
|
||||||
normalized.append({
|
|
||||||
'norad_id': norad_id,
|
|
||||||
'name': name,
|
|
||||||
'tle_line1': tle1,
|
|
||||||
'tle_line2': tle2,
|
|
||||||
'enabled': bool(enabled),
|
|
||||||
'builtin': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Single inserts preserve previous behavior; list inserts use DB-level bulk path.
|
|
||||||
if len(normalized) == 1:
|
|
||||||
sat = normalized[0]
|
|
||||||
added = 1 if add_tracked_satellite(
|
|
||||||
sat['norad_id'],
|
|
||||||
sat['name'],
|
|
||||||
sat.get('tle_line1'),
|
|
||||||
sat.get('tle_line2'),
|
|
||||||
sat.get('enabled', True),
|
|
||||||
sat.get('builtin', False),
|
|
||||||
) else 0
|
|
||||||
else:
|
|
||||||
added = bulk_add_tracked_satellites(normalized)
|
|
||||||
|
|
||||||
response_payload = {
|
|
||||||
'status': 'success',
|
|
||||||
'added': added,
|
|
||||||
'processed': len(normalized),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Returning all tracked satellites for very large imports can stall the UI.
|
|
||||||
include_satellites = request.args.get('include_satellites', '').lower() == 'true'
|
|
||||||
if include_satellites or len(normalized) <= 32:
|
|
||||||
response_payload['satellites'] = get_tracked_satellites()
|
|
||||||
|
|
||||||
return jsonify(response_payload)
|
|
||||||
|
|
||||||
|
|
||||||
@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 api_error('Missing enabled field', 400)
|
|
||||||
|
|
||||||
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
|
||||||
if ok:
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
return api_error('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 api_error(msg, status_code)
|
|
||||||
|
|||||||
@@ -2,73 +2,30 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.process import register_process, unregister_process
|
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_frequency,
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
validate_gain,
|
|
||||||
validate_ppm,
|
|
||||||
validate_rtl_tcp_host,
|
|
||||||
validate_rtl_tcp_port,
|
|
||||||
)
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
sensor_bp = Blueprint('sensor', __name__)
|
sensor_bp = Blueprint('sensor', __name__)
|
||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sensor_active_device: int | None = None
|
sensor_active_device: int | None = None
|
||||||
sensor_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
|
||||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
|
||||||
_MAX_RSSI_HISTORY = 60
|
|
||||||
|
|
||||||
|
|
||||||
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
|
||||||
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
|
||||||
points = max(32, min(points, 512))
|
|
||||||
|
|
||||||
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
|
||||||
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
|
||||||
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
|
||||||
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
|
||||||
|
|
||||||
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
|
||||||
cycles = 3.0 + (snr_norm * 8.0)
|
|
||||||
harmonic = 0.25 + (0.35 * snr_norm)
|
|
||||||
hiss = 0.08 + (0.18 * noise_norm)
|
|
||||||
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
|
||||||
|
|
||||||
waveform: list[int] = []
|
|
||||||
for i in range(points):
|
|
||||||
t = i / (points - 1)
|
|
||||||
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
|
||||||
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
|
||||||
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
|
||||||
|
|
||||||
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
|
||||||
sample /= (1.0 + harmonic + hiss)
|
|
||||||
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
|
||||||
waveform.append(max(-127, min(127, packed)))
|
|
||||||
|
|
||||||
return waveform
|
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
@@ -87,40 +44,6 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
# Track RSSI history per device
|
|
||||||
_model = data.get('model', '')
|
|
||||||
_dev_id = data.get('id', '')
|
|
||||||
_rssi_val = data.get('rssi')
|
|
||||||
if _rssi_val is not None and _model:
|
|
||||||
_hist_key = f"{_model}_{_dev_id}"
|
|
||||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
|
||||||
hist.append((time.time(), float(_rssi_val)))
|
|
||||||
if len(hist) > _MAX_RSSI_HISTORY:
|
|
||||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
|
||||||
|
|
||||||
# Push scope event when signal level data is present
|
|
||||||
rssi = data.get('rssi')
|
|
||||||
snr = data.get('snr')
|
|
||||||
noise = data.get('noise')
|
|
||||||
if rssi is not None or snr is not None:
|
|
||||||
try:
|
|
||||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
|
||||||
snr_value = float(snr) if snr is not None else 0.0
|
|
||||||
noise_value = float(noise) if noise is not None else 0.0
|
|
||||||
app_module.sensor_queue.put_nowait({
|
|
||||||
'type': 'scope',
|
|
||||||
'rssi': rssi_value,
|
|
||||||
'snr': snr_value,
|
|
||||||
'noise': noise_value,
|
|
||||||
'waveform': _build_scope_waveform(
|
|
||||||
rssi=rssi_value,
|
|
||||||
snr=snr_value,
|
|
||||||
noise=noise_value,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
except (TypeError, ValueError, queue.Full):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -136,40 +59,33 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global sensor_active_device, sensor_active_sdr_type
|
global sensor_active_device
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
sensor_active_sdr_type = None
|
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/sensor/status')
|
|
||||||
def sensor_status() -> Response:
|
|
||||||
"""Check if sensor decoder is currently running."""
|
|
||||||
with app_module.sensor_lock:
|
|
||||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
|
||||||
return jsonify({'running': running})
|
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
global sensor_active_device, sensor_active_sdr_type
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
return api_error('Sensor already running', 409)
|
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -180,23 +96,23 @@ def start_sensor() -> Response:
|
|||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
# Get SDR type early so we can pass it to claim/release
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
|
|
||||||
# Claim local device if not using remote rtl_tcp
|
# Claim local device if not using remote rtl_tcp
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||||
if error:
|
if error:
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
sensor_active_device = device_int
|
sensor_active_device = device_int
|
||||||
sensor_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.sensor_queue.empty():
|
while not app_module.sensor_queue.empty():
|
||||||
@@ -205,7 +121,8 @@ def start_sensor() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Build command via SDR abstraction layer
|
# Get SDR type and build command via abstraction layer
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -217,7 +134,7 @@ def start_sensor() -> Response:
|
|||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
@@ -240,10 +157,6 @@ def start_sensor() -> Response:
|
|||||||
full_cmd = ' '.join(cmd)
|
full_cmd = ' '.join(cmd)
|
||||||
logger.info(f"Running: {full_cmd}")
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
|
||||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
|
||||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.sensor_process = subprocess.Popen(
|
app_module.sensor_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -258,16 +171,10 @@ def start_sensor() -> Response:
|
|||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
# Monitor stderr
|
# Monitor stderr
|
||||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
|
||||||
_stderr_noise = (
|
|
||||||
'bitbuffer_add_bit',
|
|
||||||
'row count limit',
|
|
||||||
)
|
|
||||||
|
|
||||||
def monitor_stderr():
|
def monitor_stderr():
|
||||||
for line in app_module.sensor_process.stderr:
|
for line in app_module.sensor_process.stderr:
|
||||||
err = line.decode('utf-8', errors='replace').strip()
|
err = line.decode('utf-8', errors='replace').strip()
|
||||||
if err and not any(noise in err for noise in _stderr_noise):
|
if err:
|
||||||
logger.debug(f"[rtl_433] {err}")
|
logger.debug(f"[rtl_433] {err}")
|
||||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||||
|
|
||||||
@@ -282,22 +189,20 @@ def start_sensor() -> Response:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
sensor_active_sdr_type = None
|
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||||
return api_error('rtl_433 not found. Install with: brew install rtl_433')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
sensor_active_sdr_type = None
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
return api_error(str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
def stop_sensor() -> Response:
|
def stop_sensor() -> Response:
|
||||||
global sensor_active_device, sensor_active_sdr_type
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
@@ -310,9 +215,8 @@ def stop_sensor() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
sensor_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -321,29 +225,23 @@ def stop_sensor() -> Response:
|
|||||||
|
|
||||||
@sensor_bp.route('/stream_sensor')
|
@sensor_bp.route('/stream_sensor')
|
||||||
def stream_sensor() -> Response:
|
def stream_sensor() -> Response:
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('sensor', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=app_module.sensor_queue,
|
msg = app_module.sensor_queue.get(timeout=1)
|
||||||
channel_key='sensor',
|
last_keepalive = time.time()
|
||||||
timeout=1.0,
|
yield format_sse(msg)
|
||||||
keepalive_interval=30.0,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= keepalive_interval:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/sensor/rssi_history')
|
|
||||||
def get_rssi_history() -> Response:
|
|
||||||
"""Return RSSI history for all tracked sensor devices."""
|
|
||||||
result = {}
|
|
||||||
for key, entries in sensor_rssi_history.items():
|
|
||||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
|
||||||
return api_success(data={'devices': result})
|
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
|
get_setting,
|
||||||
|
set_setting,
|
||||||
delete_setting,
|
delete_setting,
|
||||||
get_all_settings,
|
get_all_settings,
|
||||||
get_correlations,
|
get_correlations,
|
||||||
get_setting,
|
|
||||||
set_setting,
|
|
||||||
)
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.responses import api_error, api_success
|
|
||||||
|
|
||||||
logger = get_logger('intercept.settings')
|
logger = get_logger('intercept.settings')
|
||||||
|
|
||||||
@@ -28,10 +27,16 @@ def get_settings() -> Response:
|
|||||||
"""Get all settings."""
|
"""Get all settings."""
|
||||||
try:
|
try:
|
||||||
settings = get_all_settings()
|
settings = get_all_settings()
|
||||||
return api_success(data={'settings': settings})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'settings': settings
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting settings: {e}")
|
logger.error(f"Error getting settings: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('', methods=['POST'])
|
@settings_bp.route('', methods=['POST'])
|
||||||
@@ -40,7 +45,10 @@ def save_settings() -> Response:
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return api_error('No settings provided', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No settings provided'
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
saved = []
|
saved = []
|
||||||
@@ -52,10 +60,16 @@ def save_settings() -> Response:
|
|||||||
set_setting(key, value)
|
set_setting(key, value)
|
||||||
saved.append(key)
|
saved.append(key)
|
||||||
|
|
||||||
return api_success(data={'saved': saved})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'saved': saved
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving settings: {e}")
|
logger.error(f"Error saving settings: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['GET'])
|
@settings_bp.route('/<key>', methods=['GET'])
|
||||||
@@ -69,10 +83,17 @@ def get_single_setting(key: str) -> Response:
|
|||||||
'key': key
|
'key': key
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
return api_success(data={'key': key, 'value': value})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'value': value
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting setting {key}: {e}")
|
logger.error(f"Error getting setting {key}: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['PUT'])
|
@settings_bp.route('/<key>', methods=['PUT'])
|
||||||
@@ -82,14 +103,24 @@ def update_single_setting(key: str) -> Response:
|
|||||||
value = data.get('value')
|
value = data.get('value')
|
||||||
|
|
||||||
if value is None and 'value' not in data:
|
if value is None and 'value' not in data:
|
||||||
return api_error('Value is required', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Value is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
set_setting(key, value)
|
set_setting(key, value)
|
||||||
return api_success(data={'key': key, 'value': value})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'value': value
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating setting {key}: {e}")
|
logger.error(f"Error updating setting {key}: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||||
@@ -98,7 +129,11 @@ def delete_single_setting(key: str) -> Response:
|
|||||||
try:
|
try:
|
||||||
deleted = delete_setting(key)
|
deleted = delete_setting(key)
|
||||||
if deleted:
|
if deleted:
|
||||||
return api_success(data={'key': key, 'deleted': True})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'deleted': True
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'not_found',
|
'status': 'not_found',
|
||||||
@@ -106,7 +141,10 @@ def delete_single_setting(key: str) -> Response:
|
|||||||
}), 404
|
}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting setting {key}: {e}")
|
logger.error(f"Error deleting setting {key}: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -120,10 +158,16 @@ def get_device_correlations() -> Response:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
correlations = get_correlations(min_confidence)
|
correlations = get_correlations(min_confidence)
|
||||||
return api_success(data={'correlations': correlations})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'correlations': correlations
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting correlations: {e}")
|
logger.error(f"Error getting correlations: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -163,7 +207,7 @@ def check_dvb_driver_status() -> Response:
|
|||||||
blacklist_contents = []
|
blacklist_contents = []
|
||||||
if blacklist_exists:
|
if blacklist_exists:
|
||||||
try:
|
try:
|
||||||
with open(BLACKLIST_FILE) as f:
|
with open(BLACKLIST_FILE, 'r') as f:
|
||||||
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -185,11 +229,17 @@ def check_dvb_driver_status() -> Response:
|
|||||||
def blacklist_dvb_drivers() -> Response:
|
def blacklist_dvb_drivers() -> Response:
|
||||||
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||||
if sys.platform != 'linux':
|
if sys.platform != 'linux':
|
||||||
return api_error('This feature is only available on Linux', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'This feature is only available on Linux'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Check if we have permission (need to be running as root or with sudo)
|
# Check if we have permission (need to be running as root or with sudo)
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
|
||||||
|
}), 403
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
successes = []
|
successes = []
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.responses import api_error
|
|
||||||
|
|
||||||
logger = get_logger('intercept.signalid')
|
|
||||||
|
|
||||||
signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid')
|
|
||||||
|
|
||||||
SIGID_API_URL = 'https://www.sigidwiki.com/api.php'
|
|
||||||
SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0'
|
|
||||||
SIGID_TIMEOUT_SECONDS = 12
|
|
||||||
SIGID_CACHE_TTL_SECONDS = 600
|
|
||||||
|
|
||||||
_cache: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_get(key: str) -> Any | None:
|
|
||||||
entry = _cache.get(key)
|
|
||||||
if not entry:
|
|
||||||
return None
|
|
||||||
if time.time() >= entry['expires']:
|
|
||||||
_cache.pop(key, None)
|
|
||||||
return None
|
|
||||||
return entry['data']
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
|
|
||||||
_cache[key] = {
|
|
||||||
'data': data,
|
|
||||||
'expires': time.time() + ttl_seconds,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
|
|
||||||
query = urllib.parse.urlencode(params, doseq=True)
|
|
||||||
url = f'{SIGID_API_URL}?{query}'
|
|
||||||
req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
|
|
||||||
payload = resp.read().decode('utf-8', errors='replace')
|
|
||||||
data = json.loads(payload)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning('SigID API request failed: %s', exc)
|
|
||||||
return None
|
|
||||||
if isinstance(data, dict) and data.get('error'):
|
|
||||||
logger.warning('SigID API returned error: %s', data.get('error'))
|
|
||||||
return None
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _ask_query(query: str) -> dict[str, Any] | None:
|
|
||||||
return _fetch_api_json({
|
|
||||||
'action': 'ask',
|
|
||||||
'query': query,
|
|
||||||
'format': 'json',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
|
|
||||||
return _fetch_api_json({
|
|
||||||
'action': 'query',
|
|
||||||
'list': 'search',
|
|
||||||
'srsearch': search_text,
|
|
||||||
'srlimit': str(limit),
|
|
||||||
'format': 'json',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _to_float_list(values: Any) -> list[float]:
|
|
||||||
if not isinstance(values, list):
|
|
||||||
return []
|
|
||||||
out: list[float] = []
|
|
||||||
for value in values:
|
|
||||||
try:
|
|
||||||
out.append(float(value))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _to_text_list(values: Any) -> list[str]:
|
|
||||||
if not isinstance(values, list):
|
|
||||||
return []
|
|
||||||
out: list[str] = []
|
|
||||||
for value in values:
|
|
||||||
text = str(value or '').strip()
|
|
||||||
if text:
|
|
||||||
out.append(text)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_modes(values: list[str]) -> list[str]:
|
|
||||||
out: list[str] = []
|
|
||||||
for value in values:
|
|
||||||
for token in str(value).replace('/', ',').split(','):
|
|
||||||
mode = token.strip().upper()
|
|
||||||
if mode and mode not in out:
|
|
||||||
out.append(mode)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
||||||
results = data.get('query', {}).get('results', {})
|
|
||||||
if not isinstance(results, dict):
|
|
||||||
return []
|
|
||||||
|
|
||||||
matches: list[dict[str, Any]] = []
|
|
||||||
for title, entry in results.items():
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
printouts = entry.get('printouts', {})
|
|
||||||
if not isinstance(printouts, dict):
|
|
||||||
printouts = {}
|
|
||||||
|
|
||||||
frequencies_hz = _to_float_list(printouts.get('Frequencies'))
|
|
||||||
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
|
|
||||||
|
|
||||||
modes = _normalize_modes(_to_text_list(printouts.get('Mode')))
|
|
||||||
modulations = _normalize_modes(_to_text_list(printouts.get('Modulation')))
|
|
||||||
|
|
||||||
match = {
|
|
||||||
'title': str(entry.get('fulltext') or title),
|
|
||||||
'url': str(entry.get('fullurl') or ''),
|
|
||||||
'frequencies_mhz': frequencies_mhz,
|
|
||||||
'modes': modes,
|
|
||||||
'modulations': modulations,
|
|
||||||
'source': 'SigID Wiki',
|
|
||||||
}
|
|
||||||
matches.append(match)
|
|
||||||
|
|
||||||
return matches
|
|
||||||
|
|
||||||
|
|
||||||
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
deduped: dict[str, dict[str, Any]] = {}
|
|
||||||
for match in matches:
|
|
||||||
key = f"{match.get('title', '')}|{match.get('url', '')}"
|
|
||||||
if key not in deduped:
|
|
||||||
deduped[key] = match
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Merge frequencies/modes/modulations from duplicates.
|
|
||||||
existing = deduped[key]
|
|
||||||
for field in ('frequencies_mhz', 'modes', 'modulations'):
|
|
||||||
base = existing.get(field, [])
|
|
||||||
extra = match.get(field, [])
|
|
||||||
if not isinstance(base, list):
|
|
||||||
base = []
|
|
||||||
if not isinstance(extra, list):
|
|
||||||
extra = []
|
|
||||||
merged = list(base)
|
|
||||||
for item in extra:
|
|
||||||
if item not in merged:
|
|
||||||
merged.append(item)
|
|
||||||
existing[field] = merged
|
|
||||||
return list(deduped.values())
|
|
||||||
|
|
||||||
|
|
||||||
def _rank_matches(
|
|
||||||
matches: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
frequency_mhz: float,
|
|
||||||
modulation: str,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
target_hz = frequency_mhz * 1e6
|
|
||||||
wanted_mod = str(modulation or '').strip().upper()
|
|
||||||
|
|
||||||
def score(match: dict[str, Any]) -> tuple[int, float, str]:
|
|
||||||
score_value = 0
|
|
||||||
freqs_mhz = match.get('frequencies_mhz') or []
|
|
||||||
distances_hz: list[float] = []
|
|
||||||
for f_mhz in freqs_mhz:
|
|
||||||
try:
|
|
||||||
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
min_distance_hz = min(distances_hz) if distances_hz else 1e12
|
|
||||||
|
|
||||||
if min_distance_hz <= 100:
|
|
||||||
score_value += 120
|
|
||||||
elif min_distance_hz <= 1_000:
|
|
||||||
score_value += 90
|
|
||||||
elif min_distance_hz <= 10_000:
|
|
||||||
score_value += 70
|
|
||||||
elif min_distance_hz <= 100_000:
|
|
||||||
score_value += 40
|
|
||||||
|
|
||||||
if wanted_mod:
|
|
||||||
modes = [str(v).upper() for v in (match.get('modes') or [])]
|
|
||||||
modulations = [str(v).upper() for v in (match.get('modulations') or [])]
|
|
||||||
if wanted_mod in modes:
|
|
||||||
score_value += 25
|
|
||||||
if wanted_mod in modulations:
|
|
||||||
score_value += 25
|
|
||||||
|
|
||||||
title = str(match.get('title') or '')
|
|
||||||
title_lower = title.lower()
|
|
||||||
if 'unidentified' in title_lower or 'unknown' in title_lower:
|
|
||||||
score_value -= 10
|
|
||||||
|
|
||||||
return (score_value, min_distance_hz, title.lower())
|
|
||||||
|
|
||||||
ranked = sorted(matches, key=score, reverse=True)
|
|
||||||
for match in ranked:
|
|
||||||
try:
|
|
||||||
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or []))
|
|
||||||
match['distance_hz'] = int(round(nearest))
|
|
||||||
except Exception:
|
|
||||||
match['distance_hz'] = None
|
|
||||||
return ranked
|
|
||||||
|
|
||||||
|
|
||||||
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
|
|
||||||
variants = [
|
|
||||||
f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'),
|
|
||||||
f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'),
|
|
||||||
f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'),
|
|
||||||
]
|
|
||||||
out: list[str] = []
|
|
||||||
for value in variants:
|
|
||||||
if value and value not in out:
|
|
||||||
out.append(value)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
|
|
||||||
all_matches: list[dict[str, Any]] = []
|
|
||||||
exact_queries: list[str] = []
|
|
||||||
|
|
||||||
for freq_token in _format_freq_variants_mhz(frequency_mhz):
|
|
||||||
query = (
|
|
||||||
f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]'
|
|
||||||
f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}'
|
|
||||||
)
|
|
||||||
exact_queries.append(query)
|
|
||||||
data = _ask_query(query)
|
|
||||||
if data:
|
|
||||||
all_matches.extend(_extract_matches_from_ask(data))
|
|
||||||
if all_matches:
|
|
||||||
break
|
|
||||||
|
|
||||||
search_used = False
|
|
||||||
if not all_matches:
|
|
||||||
search_used = True
|
|
||||||
search_terms = [f'{frequency_mhz:.4f} MHz']
|
|
||||||
if modulation:
|
|
||||||
search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}')
|
|
||||||
|
|
||||||
seen_titles: set[str] = set()
|
|
||||||
for term in search_terms:
|
|
||||||
search_data = _search_query(term, max(5, min(limit * 2, 10)))
|
|
||||||
search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else []
|
|
||||||
if not isinstance(search_results, list) or not search_results:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for item in search_results:
|
|
||||||
title = str(item.get('title') or '').strip()
|
|
||||||
if not title or title in seen_titles:
|
|
||||||
continue
|
|
||||||
seen_titles.add(title)
|
|
||||||
page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1'
|
|
||||||
page_data = _ask_query(page_query)
|
|
||||||
if page_data:
|
|
||||||
all_matches.extend(_extract_matches_from_ask(page_data))
|
|
||||||
if len(all_matches) >= max(limit * 3, 12):
|
|
||||||
break
|
|
||||||
if all_matches:
|
|
||||||
break
|
|
||||||
|
|
||||||
deduped = _dedupe_matches(all_matches)
|
|
||||||
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
|
|
||||||
return {
|
|
||||||
'matches': ranked[:limit],
|
|
||||||
'search_used': search_used,
|
|
||||||
'exact_queries': exact_queries,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@signalid_bp.route('/sigidwiki', methods=['POST'])
|
|
||||||
def sigidwiki_lookup() -> Response:
|
|
||||||
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
|
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
freq_raw = payload.get('frequency_mhz')
|
|
||||||
if freq_raw is None:
|
|
||||||
return api_error('frequency_mhz is required', 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
frequency_mhz = float(freq_raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return api_error('Invalid frequency_mhz', 400)
|
|
||||||
|
|
||||||
if frequency_mhz <= 0:
|
|
||||||
return api_error('frequency_mhz must be positive', 400)
|
|
||||||
|
|
||||||
modulation = str(payload.get('modulation') or '').strip().upper()
|
|
||||||
if modulation and len(modulation) > 16:
|
|
||||||
modulation = modulation[:16]
|
|
||||||
|
|
||||||
limit_raw = payload.get('limit', 8)
|
|
||||||
try:
|
|
||||||
limit = int(limit_raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
limit = 8
|
|
||||||
limit = max(1, min(limit, 20))
|
|
||||||
|
|
||||||
cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}'
|
|
||||||
cached = _cache_get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'ok',
|
|
||||||
'source': 'sigidwiki',
|
|
||||||
'frequency_mhz': round(frequency_mhz, 6),
|
|
||||||
'modulation': modulation or None,
|
|
||||||
'cached': True,
|
|
||||||
**cached,
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error('SigID lookup failed: %s', exc)
|
|
||||||
return api_error('SigID lookup failed', 502)
|
|
||||||
|
|
||||||
response_payload = {
|
|
||||||
'matches': lookup.get('matches', []),
|
|
||||||
'match_count': len(lookup.get('matches', [])),
|
|
||||||
'search_used': bool(lookup.get('search_used')),
|
|
||||||
'exact_queries': lookup.get('exact_queries', []),
|
|
||||||
}
|
|
||||||
_cache_set(cache_key, response_payload)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'ok',
|
|
||||||
'source': 'sigidwiki',
|
|
||||||
'frequency_mhz': round(frequency_mhz, 6),
|
|
||||||
'modulation': modulation or None,
|
|
||||||
'cached': False,
|
|
||||||
**response_payload,
|
|
||||||
})
|
|
||||||
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import concurrent.futures
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify
|
|
||||||
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.responses import api_error
|
|
||||||
|
|
||||||
logger = get_logger('intercept.space_weather')
|
|
||||||
|
|
||||||
space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather')
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TTL Cache
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_cache: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
# Cache TTLs in seconds
|
|
||||||
TTL_REALTIME = 300 # 5 min for real-time data
|
|
||||||
TTL_FORECAST = 1800 # 30 min for forecasts
|
|
||||||
TTL_DAILY = 3600 # 1 hr for daily summaries
|
|
||||||
TTL_IMAGE = 600 # 10 min for images
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_get(key: str) -> Any | None:
|
|
||||||
entry = _cache.get(key)
|
|
||||||
if entry and time.time() < entry['expires']:
|
|
||||||
return entry['data']
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_set(key: str, data: Any, ttl: int) -> None:
|
|
||||||
_cache[key] = {'data': data, 'expires': time.time() + ttl}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# HTTP helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_TIMEOUT = 15 # seconds
|
|
||||||
|
|
||||||
SWPC_BASE = 'https://services.swpc.noaa.gov'
|
|
||||||
SWPC_JSON = f'{SWPC_BASE}/products'
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
return json.loads(resp.read().decode())
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning('Failed to fetch %s: %s', url, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
return resp.read().decode()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning('Failed to fetch %s: %s', url, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
return resp.read()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning('Failed to fetch %s: %s', url, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Data source fetchers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
|
|
||||||
cached = _cache_get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
data = _fetch_json(url)
|
|
||||||
if data is not None:
|
|
||||||
_cache_set(cache_key, data, ttl)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_kp_index() -> Any | None:
|
|
||||||
return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_kp_forecast() -> Any | None:
|
|
||||||
return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_scales() -> Any | None:
|
|
||||||
return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_flux() -> Any | None:
|
|
||||||
return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_alerts() -> Any | None:
|
|
||||||
return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_solar_wind_plasma() -> Any | None:
|
|
||||||
return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_solar_wind_mag() -> Any | None:
|
|
||||||
return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_xrays() -> Any | None:
|
|
||||||
return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_xray_flares() -> Any | None:
|
|
||||||
return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_flare_probability() -> Any | None:
|
|
||||||
return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_solar_regions() -> Any | None:
|
|
||||||
return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_sunspot_report() -> Any | None:
|
|
||||||
return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
|
|
||||||
"""Parse HamQSL solar XML into a dict of band conditions."""
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(xml_text)
|
|
||||||
solar = root.find('.//solardata')
|
|
||||||
if solar is None:
|
|
||||||
return None
|
|
||||||
result: dict[str, Any] = {}
|
|
||||||
# Scalar fields
|
|
||||||
for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots',
|
|
||||||
'heliumline', 'protonflux', 'electonflux', 'aurora',
|
|
||||||
'normalization', 'latdegree', 'solarwind', 'magneticfield',
|
|
||||||
'calculatedconditions', 'calculatedvhfconditions',
|
|
||||||
'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'):
|
|
||||||
el = solar.find(tag)
|
|
||||||
if el is not None and el.text:
|
|
||||||
result[tag] = el.text.strip()
|
|
||||||
# Band conditions
|
|
||||||
bands: list[dict[str, str]] = []
|
|
||||||
for band_el in solar.findall('.//calculatedconditions/band'):
|
|
||||||
bands.append({
|
|
||||||
'name': band_el.get('name', ''),
|
|
||||||
'time': band_el.get('time', ''),
|
|
||||||
'condition': band_el.text.strip() if band_el.text else ''
|
|
||||||
})
|
|
||||||
result['bands'] = bands
|
|
||||||
# VHF conditions
|
|
||||||
vhf: list[dict[str, str]] = []
|
|
||||||
for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'):
|
|
||||||
vhf.append({
|
|
||||||
'name': phen_el.get('name', ''),
|
|
||||||
'location': phen_el.get('location', ''),
|
|
||||||
'condition': phen_el.text.strip() if phen_el.text else ''
|
|
||||||
})
|
|
||||||
result['vhf'] = vhf
|
|
||||||
return result
|
|
||||||
except ET.ParseError as exc:
|
|
||||||
logger.warning('Failed to parse HamQSL XML: %s', exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_band_conditions() -> dict[str, Any] | None:
|
|
||||||
cached = _cache_get('band_conditions')
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php')
|
|
||||||
if xml_text is None:
|
|
||||||
return None
|
|
||||||
data = _parse_hamqsl_xml(xml_text)
|
|
||||||
if data is not None:
|
|
||||||
_cache_set('band_conditions', data, TTL_FORECAST)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Image proxy whitelist
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
IMAGE_WHITELIST: dict[str, dict[str, str]] = {
|
|
||||||
# D-RAP absorption maps
|
|
||||||
'drap_global': {
|
|
||||||
'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png',
|
|
||||||
'content_type': 'image/png',
|
|
||||||
},
|
|
||||||
'drap_5': {
|
|
||||||
'url': f'{SWPC_BASE}/images/d-rap/global_f05.png',
|
|
||||||
'content_type': 'image/png',
|
|
||||||
},
|
|
||||||
'drap_10': {
|
|
||||||
'url': f'{SWPC_BASE}/images/d-rap/global_f10.png',
|
|
||||||
'content_type': 'image/png',
|
|
||||||
},
|
|
||||||
'drap_15': {
|
|
||||||
'url': f'{SWPC_BASE}/images/d-rap/global_f15.png',
|
|
||||||
'content_type': 'image/png',
|
|
||||||
},
|
|
||||||
'drap_20': {
|
|
||||||
'url': f'{SWPC_BASE}/images/d-rap/global_f20.png',
|
|
||||||
'content_type': 'image/png',
|
|
||||||
},
|
|
||||||
'drap_25': {
|
|
||||||
'url': f'{SWPC_BASE}/images/d-rap/global_f25.png',
|
|
||||||
'content_type': 'image/png',
|
|
||||||
},
|
|
||||||
'drap_30': {
|
|
||||||
'url': f'{SWPC_BASE}/images/d-rap/global_f30.png',
|
|
||||||
'content_type': 'image/png',
|
|
||||||
},
|
|
||||||
# Aurora forecast
|
|
||||||
'aurora_north': {
|
|
||||||
'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg',
|
|
||||||
'content_type': 'image/jpeg',
|
|
||||||
},
|
|
||||||
# SDO solar imagery
|
|
||||||
'sdo_193': {
|
|
||||||
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
|
|
||||||
'content_type': 'image/jpeg',
|
|
||||||
},
|
|
||||||
'sdo_304': {
|
|
||||||
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
|
|
||||||
'content_type': 'image/jpeg',
|
|
||||||
},
|
|
||||||
'sdo_magnetogram': {
|
|
||||||
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
|
|
||||||
'content_type': 'image/jpeg',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Routes
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@space_weather_bp.route('/data')
|
|
||||||
def get_data():
|
|
||||||
"""Return aggregated space weather data from all sources."""
|
|
||||||
fetchers = {
|
|
||||||
'kp_index': _fetch_kp_index,
|
|
||||||
'kp_forecast': _fetch_kp_forecast,
|
|
||||||
'scales': _fetch_scales,
|
|
||||||
'flux': _fetch_flux,
|
|
||||||
'alerts': _fetch_alerts,
|
|
||||||
'solar_wind_plasma': _fetch_solar_wind_plasma,
|
|
||||||
'solar_wind_mag': _fetch_solar_wind_mag,
|
|
||||||
'xrays': _fetch_xrays,
|
|
||||||
'xray_flares': _fetch_xray_flares,
|
|
||||||
'flare_probability': _fetch_flare_probability,
|
|
||||||
'solar_regions': _fetch_solar_regions,
|
|
||||||
'sunspot_report': _fetch_sunspot_report,
|
|
||||||
'band_conditions': _fetch_band_conditions,
|
|
||||||
}
|
|
||||||
data = {}
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=13) as executor:
|
|
||||||
futures = {executor.submit(fn): key for key, fn in fetchers.items()}
|
|
||||||
for future in concurrent.futures.as_completed(futures):
|
|
||||||
data[futures[future]] = future.result()
|
|
||||||
data['timestamp'] = time.time()
|
|
||||||
return jsonify(data)
|
|
||||||
|
|
||||||
|
|
||||||
@space_weather_bp.route('/image/<key>')
|
|
||||||
def get_image(key: str):
|
|
||||||
"""Proxy and cache whitelisted space weather images."""
|
|
||||||
entry = IMAGE_WHITELIST.get(key)
|
|
||||||
if not entry:
|
|
||||||
return api_error('Unknown image key', 404)
|
|
||||||
|
|
||||||
cache_key = f'img_{key}'
|
|
||||||
cached = _cache_get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return Response(cached, content_type=entry['content_type'],
|
|
||||||
headers={'Cache-Control': 'public, max-age=300'})
|
|
||||||
|
|
||||||
img_data = _fetch_bytes(entry['url'])
|
|
||||||
if img_data is None:
|
|
||||||
return api_error('Failed to fetch image', 502)
|
|
||||||
|
|
||||||
_cache_set(cache_key, img_data, TTL_IMAGE)
|
|
||||||
return Response(img_data, content_type=entry['content_type'],
|
|
||||||
headers={'Cache-Control': 'public, max-age=300'})
|
|
||||||
|
|
||||||
|
|
||||||
@space_weather_bp.route('/prefetch-images')
|
|
||||||
def prefetch_images():
|
|
||||||
"""Warm the image cache by fetching all whitelisted images in parallel."""
|
|
||||||
# Only fetch images not already cached
|
|
||||||
to_fetch = {}
|
|
||||||
for key, entry in IMAGE_WHITELIST.items():
|
|
||||||
cache_key = f'img_{key}'
|
|
||||||
if _cache_get(cache_key) is None:
|
|
||||||
to_fetch[key] = entry
|
|
||||||
|
|
||||||
if not to_fetch:
|
|
||||||
return jsonify({'status': 'all cached', 'count': 0})
|
|
||||||
|
|
||||||
def _fetch_and_cache(key: str, entry: dict) -> bool:
|
|
||||||
img_data = _fetch_bytes(entry['url'])
|
|
||||||
if img_data:
|
|
||||||
_cache_set(f'img_{key}', img_data, TTL_IMAGE)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
fetched = 0
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
|
||||||
futures = {
|
|
||||||
executor.submit(_fetch_and_cache, k, e): k
|
|
||||||
for k, e in to_fetch.items()
|
|
||||||
}
|
|
||||||
for future in concurrent.futures.as_completed(futures):
|
|
||||||
if future.result():
|
|
||||||
fetched += 1
|
|
||||||
|
|
||||||
return jsonify({'status': 'ok', 'fetched': fetched, 'cached': len(IMAGE_WHITELIST) - len(to_fetch)})
|
|
||||||
@@ -611,9 +611,9 @@ def get_station(station_id):
|
|||||||
@spy_stations_bp.route('/filters')
|
@spy_stations_bp.route('/filters')
|
||||||
def get_filters():
|
def get_filters():
|
||||||
"""Return available filter options."""
|
"""Return available filter options."""
|
||||||
types = list({s['type'] for s in STATIONS})
|
types = list(set(s['type'] for s in STATIONS))
|
||||||
countries = sorted({(s['country'], s['country_code']) for s in STATIONS})
|
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
|
||||||
modes = sorted({s['mode'].split('/')[0] for s in STATIONS})
|
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
|
|||||||
@@ -6,82 +6,47 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Generator
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.responses import api_error
|
from utils.sse import format_sse
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
ISS_SSTV_FREQ,
|
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
is_sstv_available,
|
is_sstv_available,
|
||||||
|
ISS_SSTV_FREQ,
|
||||||
|
DecodeProgress,
|
||||||
|
DopplerInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.sstv')
|
logger = get_logger('intercept.sstv')
|
||||||
|
|
||||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||||
|
|
||||||
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
|
||||||
# can type nearby values and still land on the canonical center frequency.
|
|
||||||
ISS_SSTV_MODULATION = 'fm'
|
|
||||||
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
|
||||||
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
|
||||||
|
|
||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Caching — ISS position (external API) and schedule (skyfield computation)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_iss_position_cache: dict | None = None
|
|
||||||
_iss_position_cache_time: float = 0
|
|
||||||
_iss_position_lock = threading.Lock()
|
|
||||||
ISS_POSITION_CACHE_TTL = 10 # seconds
|
|
||||||
|
|
||||||
_iss_schedule_cache: dict | None = None
|
|
||||||
_iss_schedule_cache_time: float = 0
|
|
||||||
_iss_schedule_cache_key: str | None = None
|
|
||||||
_iss_schedule_lock = threading.Lock()
|
|
||||||
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
|
|
||||||
|
|
||||||
# Reusable skyfield timescale (expensive to create)
|
|
||||||
_timescale = None
|
|
||||||
_timescale_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sstv_active_device: int | None = None
|
sstv_active_device: int | None = None
|
||||||
sstv_active_sdr_type: str = 'rtlsdr'
|
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(data: dict) -> None:
|
def _progress_callback(progress: DecodeProgress) -> None:
|
||||||
"""Callback to queue progress/scope updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_queue.put_nowait(data)
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_queue.get_nowait()
|
_sstv_queue.get_nowait()
|
||||||
_sstv_queue.put_nowait(data)
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
|
||||||
"""Snap near-match user input to a supported ISS SSTV center frequency."""
|
|
||||||
for supported in ISS_SSTV_FREQUENCIES:
|
|
||||||
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
|
|
||||||
return supported
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/status')
|
@sstv_bp.route('/status')
|
||||||
def get_status():
|
def get_status():
|
||||||
"""
|
"""
|
||||||
@@ -98,7 +63,6 @@ def get_status():
|
|||||||
'decoder': decoder.decoder_available,
|
'decoder': decoder.decoder_available,
|
||||||
'running': decoder.is_running,
|
'running': decoder.is_running,
|
||||||
'iss_frequency': ISS_SSTV_FREQ,
|
'iss_frequency': ISS_SSTV_FREQ,
|
||||||
'modulation': ISS_SSTV_MODULATION,
|
|
||||||
'image_count': len(decoder.get_images()),
|
'image_count': len(decoder.get_images()),
|
||||||
'doppler_enabled': decoder.doppler_enabled,
|
'doppler_enabled': decoder.doppler_enabled,
|
||||||
}
|
}
|
||||||
@@ -119,7 +83,6 @@ def start_decoder():
|
|||||||
JSON body (optional):
|
JSON body (optional):
|
||||||
{
|
{
|
||||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||||
"modulation": "fm", // ISS mode is FM-only
|
|
||||||
"device": 0, // RTL-SDR device index
|
"device": 0, // RTL-SDR device index
|
||||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||||
@@ -144,7 +107,6 @@ def start_decoder():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'frequency': ISS_SSTV_FREQ,
|
'frequency': ISS_SSTV_FREQ,
|
||||||
'modulation': ISS_SSTV_MODULATION,
|
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -157,38 +119,19 @@ def start_decoder():
|
|||||||
|
|
||||||
# Get parameters
|
# Get parameters
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
|
|
||||||
if sdr_type_str != 'rtlsdr':
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||||
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
latitude = data.get('latitude')
|
latitude = data.get('latitude')
|
||||||
longitude = data.get('longitude')
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
# Validate modulation (ISS mode is FM-only)
|
|
||||||
if modulation != ISS_SSTV_MODULATION:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Validate frequency
|
# Validate frequency
|
||||||
try:
|
try:
|
||||||
frequency = float(frequency)
|
frequency = float(frequency)
|
||||||
normalized_frequency = _normalize_iss_frequency(frequency)
|
if not (100 <= frequency <= 500): # VHF range
|
||||||
if normalized_frequency is None:
|
|
||||||
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
'message': 'Frequency must be between 100-500 MHz'
|
||||||
}), 400
|
}), 400
|
||||||
frequency = normalized_frequency
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -220,9 +163,9 @@ def start_decoder():
|
|||||||
longitude = None
|
longitude = None
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
global sstv_active_device, sstv_active_sdr_type
|
global sstv_active_device
|
||||||
device_int = int(device_index)
|
device_int = int(device_index)
|
||||||
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
|
error = app_module.claim_sdr_device(device_int, 'sstv')
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -236,18 +179,15 @@ def start_decoder():
|
|||||||
frequency=frequency,
|
frequency=frequency,
|
||||||
device_index=device_index,
|
device_index=device_index,
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude
|
||||||
modulation=ISS_SSTV_MODULATION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
sstv_active_device = device_int
|
sstv_active_device = device_int
|
||||||
sstv_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'modulation': ISS_SSTV_MODULATION,
|
|
||||||
'device': device_index,
|
'device': device_index,
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
}
|
}
|
||||||
@@ -259,7 +199,7 @@ def start_decoder():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
else:
|
else:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
app_module.release_sdr_device(device_int)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder'
|
'message': 'Failed to start decoder'
|
||||||
@@ -274,13 +214,13 @@ def stop_decoder():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON confirmation.
|
JSON confirmation.
|
||||||
"""
|
"""
|
||||||
global sstv_active_device, sstv_active_sdr_type
|
global sstv_active_device
|
||||||
decoder = get_sstv_decoder()
|
decoder = get_sstv_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if sstv_active_device is not None:
|
if sstv_active_device is not None:
|
||||||
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
|
app_module.release_sdr_device(sstv_active_device)
|
||||||
sstv_active_device = None
|
sstv_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
@@ -359,16 +299,16 @@ def get_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return api_error('Invalid filename', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return api_error('Only PNG files supported', 400)
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
# Find image in decoder's output directory
|
# Find image in decoder's output directory
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return api_error('Image not found', 404)
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
@@ -388,15 +328,15 @@ def download_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return api_error('Invalid filename', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return api_error('Only PNG files supported', 400)
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return api_error('Image not found', 404)
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
@@ -416,15 +356,15 @@ def delete_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return api_error('Invalid filename', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return api_error('Only PNG files supported', 400)
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
if decoder.delete_image(filename):
|
if decoder.delete_image(filename):
|
||||||
return jsonify({'status': 'ok'})
|
return jsonify({'status': 'ok'})
|
||||||
else:
|
else:
|
||||||
return api_error('Image not found', 404)
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/images', methods=['DELETE'])
|
@sstv_bp.route('/images', methods=['DELETE'])
|
||||||
@@ -453,42 +393,34 @@ def stream_progress():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('sstv', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=_sstv_queue,
|
progress = _sstv_queue.get(timeout=1)
|
||||||
channel_key='sstv',
|
last_keepalive = time.time()
|
||||||
timeout=1.0,
|
yield format_sse(progress)
|
||||||
keepalive_interval=30.0,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= keepalive_interval:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _get_timescale():
|
|
||||||
"""Return a cached skyfield timescale (expensive to create)."""
|
|
||||||
global _timescale
|
|
||||||
with _timescale_lock:
|
|
||||||
if _timescale is None:
|
|
||||||
from skyfield.api import load
|
|
||||||
_timescale = load.timescale()
|
|
||||||
return _timescale
|
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/iss-schedule')
|
@sstv_bp.route('/iss-schedule')
|
||||||
def iss_schedule():
|
def iss_schedule():
|
||||||
"""
|
"""
|
||||||
Get ISS pass schedule for SSTV reception.
|
Get ISS pass schedule for SSTV reception.
|
||||||
|
|
||||||
Calculates ISS passes directly using skyfield.
|
Calculates ISS passes directly using skyfield.
|
||||||
Results are cached for 15 minutes per rounded location.
|
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
latitude: Observer latitude (required)
|
latitude: Observer latitude (required)
|
||||||
@@ -498,8 +430,6 @@ def iss_schedule():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON with ISS pass schedule.
|
JSON with ISS pass schedule.
|
||||||
"""
|
"""
|
||||||
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
|
|
||||||
|
|
||||||
lat = request.args.get('latitude', type=float)
|
lat = request.args.get('latitude', type=float)
|
||||||
lon = request.args.get('longitude', type=float)
|
lon = request.args.get('longitude', type=float)
|
||||||
hours = request.args.get('hours', 48, type=int)
|
hours = request.args.get('hours', 48, type=int)
|
||||||
@@ -510,22 +440,10 @@ def iss_schedule():
|
|||||||
'message': 'latitude and longitude parameters required'
|
'message': 'latitude and longitude parameters required'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
|
|
||||||
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
|
|
||||||
|
|
||||||
with _iss_schedule_lock:
|
|
||||||
now = time.time()
|
|
||||||
if (_iss_schedule_cache is not None
|
|
||||||
and cache_key == _iss_schedule_cache_key
|
|
||||||
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
|
|
||||||
return jsonify(_iss_schedule_cache)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from datetime import timedelta
|
from skyfield.api import load, wgs84, EarthSatellite
|
||||||
|
|
||||||
from skyfield.almanac import find_discrete
|
from skyfield.almanac import find_discrete
|
||||||
from skyfield.api import EarthSatellite, wgs84
|
from datetime import timedelta
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
# Get ISS TLE
|
# Get ISS TLE
|
||||||
@@ -536,7 +454,7 @@ def iss_schedule():
|
|||||||
'message': 'ISS TLE data not available'
|
'message': 'ISS TLE data not available'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
ts = _get_timescale()
|
ts = load.timescale()
|
||||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
|
||||||
@@ -599,21 +517,13 @@ def iss_schedule():
|
|||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
result = {
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'passes': passes,
|
'passes': passes,
|
||||||
'count': len(passes),
|
'count': len(passes),
|
||||||
'sstv_frequency': ISS_SSTV_FREQ,
|
'sstv_frequency': ISS_SSTV_FREQ,
|
||||||
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
||||||
}
|
})
|
||||||
|
|
||||||
# Update cache
|
|
||||||
with _iss_schedule_lock:
|
|
||||||
_iss_schedule_cache = result
|
|
||||||
_iss_schedule_cache_time = time.time()
|
|
||||||
_iss_schedule_cache_key = cache_key
|
|
||||||
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -629,65 +539,13 @@ def iss_schedule():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
def _fetch_iss_position() -> dict | None:
|
|
||||||
"""Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache."""
|
|
||||||
global _iss_position_cache, _iss_position_cache_time
|
|
||||||
|
|
||||||
with _iss_position_lock:
|
|
||||||
now = time.time()
|
|
||||||
if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL:
|
|
||||||
return _iss_position_cache
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
cached = None
|
|
||||||
|
|
||||||
# Try primary API: Where The ISS At
|
|
||||||
try:
|
|
||||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
cached = {
|
|
||||||
'lat': float(data['latitude']),
|
|
||||||
'lon': float(data['longitude']),
|
|
||||||
'altitude': float(data.get('altitude', 420)),
|
|
||||||
'source': 'wheretheiss',
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Where The ISS At API failed: {e}")
|
|
||||||
|
|
||||||
# Try fallback API: Open Notify
|
|
||||||
if cached is None:
|
|
||||||
try:
|
|
||||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
if data.get('message') == 'success':
|
|
||||||
cached = {
|
|
||||||
'lat': float(data['iss_position']['latitude']),
|
|
||||||
'lon': float(data['iss_position']['longitude']),
|
|
||||||
'altitude': 420,
|
|
||||||
'source': 'open-notify',
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Open Notify API failed: {e}")
|
|
||||||
|
|
||||||
if cached is not None:
|
|
||||||
with _iss_position_lock:
|
|
||||||
_iss_position_cache = cached
|
|
||||||
_iss_position_cache_time = time.time()
|
|
||||||
|
|
||||||
return cached
|
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/iss-position')
|
@sstv_bp.route('/iss-position')
|
||||||
def iss_position():
|
def iss_position():
|
||||||
"""
|
"""
|
||||||
Get current ISS position from real-time API.
|
Get current ISS position from real-time API.
|
||||||
|
|
||||||
Uses the "Where The ISS At" API for accurate real-time position,
|
Uses the Open Notify API for accurate real-time position,
|
||||||
with fallback to Open Notify API. Raw position is cached for 10 seconds;
|
with fallback to "Where The ISS At" API.
|
||||||
observer-relative data (elevation/azimuth) is computed per-request.
|
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
latitude: Observer latitude (optional, for elevation calc)
|
latitude: Observer latitude (optional, for elevation calc)
|
||||||
@@ -696,32 +554,68 @@ def iss_position():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON with ISS current position.
|
JSON with ISS current position.
|
||||||
"""
|
"""
|
||||||
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
observer_lat = request.args.get('latitude', type=float)
|
observer_lat = request.args.get('latitude', type=float)
|
||||||
observer_lon = request.args.get('longitude', type=float)
|
observer_lon = request.args.get('longitude', type=float)
|
||||||
|
|
||||||
pos = _fetch_iss_position()
|
# Try primary API: Where The ISS At
|
||||||
if pos is None:
|
try:
|
||||||
return jsonify({
|
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||||
'status': 'error',
|
if response.status_code == 200:
|
||||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
data = response.json()
|
||||||
}), 503
|
iss_lat = float(data['latitude'])
|
||||||
|
iss_lon = float(data['longitude'])
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'lat': pos['lat'],
|
'lat': iss_lat,
|
||||||
'lon': pos['lon'],
|
'lon': iss_lon,
|
||||||
'altitude': pos['altitude'],
|
'altitude': float(data.get('altitude', 420)),
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
'source': pos['source'],
|
'source': 'wheretheiss'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Calculate observer-relative data if location provided
|
# Calculate observer-relative data if location provided
|
||||||
if observer_lat is not None and observer_lon is not None:
|
if observer_lat is not None and observer_lon is not None:
|
||||||
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
|
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Where The ISS At API failed: {e}")
|
||||||
|
|
||||||
|
# Try fallback API: Open Notify
|
||||||
|
try:
|
||||||
|
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('message') == 'success':
|
||||||
|
iss_lat = float(data['iss_position']['latitude'])
|
||||||
|
iss_lon = float(data['iss_position']['longitude'])
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'status': 'ok',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': 420, # Approximate ISS altitude in km
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'source': 'open-notify'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Open Notify API failed: {e}")
|
||||||
|
|
||||||
|
# Both APIs failed
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
||||||
@@ -819,5 +713,7 @@ def decode_file():
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
Path(tmp_path).unlink()
|
Path(tmp_path).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -6,18 +6,17 @@ frequencies used by amateur radio operators worldwide.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import queue
|
import queue
|
||||||
|
import time
|
||||||
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.responses import api_error
|
from utils.sse import format_sse
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
|
DecodeProgress,
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,10 +27,6 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
|
|||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
# Track which device is being used
|
|
||||||
_sstv_general_active_device: int | None = None
|
|
||||||
_sstv_general_active_sdr_type: str = 'rtlsdr'
|
|
||||||
|
|
||||||
# Predefined SSTV frequencies
|
# Predefined SSTV frequencies
|
||||||
SSTV_FREQUENCIES = [
|
SSTV_FREQUENCIES = [
|
||||||
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
||||||
@@ -45,7 +40,7 @@ SSTV_FREQUENCIES = [
|
|||||||
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||||
{'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||||
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -53,14 +48,14 @@ SSTV_FREQUENCIES = [
|
|||||||
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(data: dict) -> None:
|
def _progress_callback(progress: DecodeProgress) -> None:
|
||||||
"""Callback to queue progress/scope updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.put_nowait(data)
|
_sstv_general_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.get_nowait()
|
_sstv_general_queue.get_nowait()
|
||||||
_sstv_general_queue.put_nowait(data)
|
_sstv_general_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -102,7 +97,10 @@ def start_decoder():
|
|||||||
decoder = get_general_sstv_decoder()
|
decoder = get_general_sstv_decoder()
|
||||||
|
|
||||||
if decoder.decoder_available is None:
|
if decoder.decoder_available is None:
|
||||||
return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
|
||||||
|
}), 400
|
||||||
|
|
||||||
if decoder.is_running:
|
if decoder.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -117,25 +115,29 @@ def start_decoder():
|
|||||||
break
|
break
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
|
|
||||||
if sdr_type_str != 'rtlsdr':
|
|
||||||
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
|
||||||
|
|
||||||
frequency = data.get('frequency')
|
frequency = data.get('frequency')
|
||||||
modulation = data.get('modulation')
|
modulation = data.get('modulation')
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
|
|
||||||
# Validate frequency
|
# Validate frequency
|
||||||
if frequency is None:
|
if frequency is None:
|
||||||
return api_error('Frequency is required', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Frequency is required',
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frequency = float(frequency)
|
frequency = float(frequency)
|
||||||
if not (1 <= frequency <= 500):
|
if not (1 <= frequency <= 500):
|
||||||
return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
|
||||||
|
}), 400
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return api_error('Invalid frequency', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid frequency',
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Auto-detect modulation from frequency table if not specified
|
# Auto-detect modulation from frequency table if not specified
|
||||||
if not modulation:
|
if not modulation:
|
||||||
@@ -143,14 +145,10 @@ def start_decoder():
|
|||||||
|
|
||||||
# Validate modulation
|
# Validate modulation
|
||||||
if modulation not in ('fm', 'usb', 'lsb'):
|
if modulation not in ('fm', 'usb', 'lsb'):
|
||||||
return api_error('Modulation must be fm, usb, or lsb', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
# Claim SDR device
|
'message': 'Modulation must be fm, usb, or lsb',
|
||||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
}), 400
|
||||||
device_int = int(device_index)
|
|
||||||
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
|
|
||||||
if error:
|
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
|
||||||
|
|
||||||
# Set callback and start
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
@@ -161,8 +159,6 @@ def start_decoder():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
_sstv_general_active_device = device_int
|
|
||||||
_sstv_general_active_sdr_type = sdr_type_str
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
@@ -170,21 +166,17 @@ def start_decoder():
|
|||||||
'device': device_index,
|
'device': device_index,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
return jsonify({
|
||||||
return api_error('Failed to start decoder', 500)
|
'status': 'error',
|
||||||
|
'message': 'Failed to start decoder',
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoder():
|
def stop_decoder():
|
||||||
"""Stop general SSTV decoder."""
|
"""Stop general SSTV decoder."""
|
||||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
|
||||||
decoder = get_general_sstv_decoder()
|
decoder = get_general_sstv_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
if _sstv_general_active_device is not None:
|
|
||||||
app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
|
|
||||||
_sstv_general_active_device = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -212,15 +204,15 @@ def get_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return api_error('Invalid filename', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return api_error('Only PNG files supported', 400)
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return api_error('Image not found', 404)
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
@@ -232,15 +224,15 @@ def download_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return api_error('Invalid filename', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return api_error('Only PNG files supported', 400)
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
image_path = decoder._output_dir / filename
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
return api_error('Image not found', 404)
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
@@ -252,15 +244,15 @@ def delete_image(filename: str):
|
|||||||
|
|
||||||
# Security: only allow alphanumeric filenames with .png extension
|
# Security: only allow alphanumeric filenames with .png extension
|
||||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
return api_error('Invalid filename', 400)
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
if not filename.endswith('.png'):
|
if not filename.endswith('.png'):
|
||||||
return api_error('Only PNG files supported', 400)
|
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||||
|
|
||||||
if decoder.delete_image(filename):
|
if decoder.delete_image(filename):
|
||||||
return jsonify({'status': 'ok'})
|
return jsonify({'status': 'ok'})
|
||||||
else:
|
else:
|
||||||
return api_error('Image not found', 404)
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
@sstv_general_bp.route('/images', methods=['DELETE'])
|
@sstv_general_bp.route('/images', methods=['DELETE'])
|
||||||
@@ -274,19 +266,22 @@ def delete_all_images():
|
|||||||
@sstv_general_bp.route('/stream')
|
@sstv_general_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""SSE stream of SSTV decode progress."""
|
"""SSE stream of SSTV decode progress."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def generate() -> Generator[str, None, None]:
|
||||||
process_event('sstv_general', msg, msg.get('type'))
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
response = Response(
|
while True:
|
||||||
sse_stream_fanout(
|
try:
|
||||||
source_queue=_sstv_general_queue,
|
progress = _sstv_general_queue.get(timeout=1)
|
||||||
channel_key='sstv_general',
|
last_keepalive = time.time()
|
||||||
timeout=1.0,
|
yield format_sse(progress)
|
||||||
keepalive_interval=30.0,
|
except queue.Empty:
|
||||||
on_message=_on_msg,
|
now = time.time()
|
||||||
),
|
if now - last_keepalive >= keepalive_interval:
|
||||||
mimetype='text/event-stream',
|
yield format_sse({'type': 'keepalive'})
|
||||||
)
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
@@ -297,12 +292,18 @@ def stream_progress():
|
|||||||
def decode_file():
|
def decode_file():
|
||||||
"""Decode SSTV from an uploaded audio file."""
|
"""Decode SSTV from an uploaded audio file."""
|
||||||
if 'audio' not in request.files:
|
if 'audio' not in request.files:
|
||||||
return api_error('No audio file provided', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No audio file provided',
|
||||||
|
}), 400
|
||||||
|
|
||||||
audio_file = request.files['audio']
|
audio_file = request.files['audio']
|
||||||
|
|
||||||
if not audio_file.filename:
|
if not audio_file.filename:
|
||||||
return api_error('No file selected', 400)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No file selected',
|
||||||
|
}), 400
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||||
@@ -321,8 +322,13 @@ def decode_file():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error decoding file: {e}")
|
logger.error(f"Error decoding file: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
}), 500
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
try:
|
||||||
Path(tmp_path).unlink()
|
Path(tmp_path).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,429 +0,0 @@
|
|||||||
"""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 contextlib
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
|
||||||
|
|
||||||
from utils.constants import (
|
|
||||||
SUBGHZ_FREQ_MAX_MHZ,
|
|
||||||
SUBGHZ_FREQ_MIN_MHZ,
|
|
||||||
SUBGHZ_LNA_GAIN_MAX,
|
|
||||||
SUBGHZ_PRESETS,
|
|
||||||
SUBGHZ_SAMPLE_RATES,
|
|
||||||
SUBGHZ_TX_MAX_DURATION,
|
|
||||||
SUBGHZ_TX_VGA_GAIN_MAX,
|
|
||||||
SUBGHZ_VGA_GAIN_MAX,
|
|
||||||
)
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sse import sse_stream
|
|
||||||
from utils.subghz import get_subghz_manager
|
|
||||||
|
|
||||||
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."""
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
process_event('subghz', event, event.get('type'))
|
|
||||||
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 api_error(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 api_error(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 api_error('capture_id is required', 400)
|
|
||||||
|
|
||||||
# Sanitize capture_id
|
|
||||||
if not capture_id.isalnum():
|
|
||||||
return api_error('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 api_error(start_err, 400)
|
|
||||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
|
||||||
if duration_err:
|
|
||||||
return api_error(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 api_error('freq_start must be less than freq_end', 400)
|
|
||||||
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
|
||||||
return api_error(f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz', 400)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return api_error('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 api_error('Invalid capture_id', 400)
|
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
|
||||||
capture = manager.get_capture(capture_id)
|
|
||||||
if not capture:
|
|
||||||
return api_error('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 api_error('Invalid capture_id', 400)
|
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
|
||||||
path = manager.get_capture_path(capture_id)
|
|
||||||
if not path:
|
|
||||||
return api_error('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 api_error('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 api_error(start_err, 400)
|
|
||||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
|
||||||
if duration_err:
|
|
||||||
return api_error(duration_err, 400)
|
|
||||||
|
|
||||||
label = data.get('label', '')
|
|
||||||
if label is None:
|
|
||||||
label = ''
|
|
||||||
if not isinstance(label, str) or len(label) > 100:
|
|
||||||
return api_error('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 api_error('Invalid capture_id', 400)
|
|
||||||
|
|
||||||
manager = get_subghz_manager()
|
|
||||||
if manager.delete_capture(capture_id):
|
|
||||||
return jsonify({'status': 'deleted', 'id': capture_id})
|
|
||||||
return api_error('Capture not found', 404)
|
|
||||||
|
|
||||||
|
|
||||||
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
|
||||||
def update_capture(capture_id: str):
|
|
||||||
if not capture_id.isalnum():
|
|
||||||
return api_error('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 api_error('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 api_error('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
|
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
"""System Health monitoring blueprint.
|
|
||||||
|
|
||||||
Provides real-time system metrics (CPU, memory, disk, temperatures,
|
|
||||||
network, battery, fans), active process status, SDR device enumeration,
|
|
||||||
location, and weather data via SSE streaming and REST endpoints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import queue
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
|
||||||
from utils.logging import sensor_logger as logger
|
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
|
|
||||||
try:
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
_HAS_PSUTIL = True
|
|
||||||
except ImportError:
|
|
||||||
psutil = None # type: ignore[assignment]
|
|
||||||
_HAS_PSUTIL = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
import requests as _requests
|
|
||||||
except ImportError:
|
|
||||||
_requests = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Background metrics collector
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_metrics_queue: queue.Queue = queue.Queue(maxsize=500)
|
|
||||||
_collector_started = False
|
|
||||||
_collector_lock = threading.Lock()
|
|
||||||
_app_start_time: float | None = None
|
|
||||||
|
|
||||||
# Weather cache
|
|
||||||
_weather_cache: dict[str, Any] = {}
|
|
||||||
_weather_cache_time: float = 0.0
|
|
||||||
_WEATHER_CACHE_TTL = 600 # 10 minutes
|
|
||||||
|
|
||||||
|
|
||||||
def _get_app_start_time() -> float:
|
|
||||||
"""Return the application start timestamp from the main app module."""
|
|
||||||
global _app_start_time
|
|
||||||
if _app_start_time is None:
|
|
||||||
try:
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
_app_start_time = getattr(app_module, '_app_start_time', time.time())
|
|
||||||
except Exception:
|
|
||||||
_app_start_time = time.time()
|
|
||||||
return _app_start_time
|
|
||||||
|
|
||||||
|
|
||||||
def _get_app_version() -> str:
|
|
||||||
"""Return the application version string."""
|
|
||||||
try:
|
|
||||||
from config import VERSION
|
|
||||||
|
|
||||||
return VERSION
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def _format_uptime(seconds: float) -> str:
|
|
||||||
"""Format seconds into a human-readable uptime string."""
|
|
||||||
days = int(seconds // 86400)
|
|
||||||
hours = int((seconds % 86400) // 3600)
|
|
||||||
minutes = int((seconds % 3600) // 60)
|
|
||||||
parts = []
|
|
||||||
if days > 0:
|
|
||||||
parts.append(f'{days}d')
|
|
||||||
if hours > 0:
|
|
||||||
parts.append(f'{hours}h')
|
|
||||||
parts.append(f'{minutes}m')
|
|
||||||
return ' '.join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_process_status() -> dict[str, bool]:
|
|
||||||
"""Return running/stopped status for each decoder process.
|
|
||||||
|
|
||||||
Mirrors the logic in app.py health_check().
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
def _alive(attr: str) -> bool:
|
|
||||||
proc = getattr(app_module, attr, None)
|
|
||||||
if proc is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return proc.poll() is None
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
processes: dict[str, bool] = {
|
|
||||||
'pager': _alive('current_process'),
|
|
||||||
'sensor': _alive('sensor_process'),
|
|
||||||
'adsb': _alive('adsb_process'),
|
|
||||||
'ais': _alive('ais_process'),
|
|
||||||
'acars': _alive('acars_process'),
|
|
||||||
'vdl2': _alive('vdl2_process'),
|
|
||||||
'aprs': _alive('aprs_process'),
|
|
||||||
'dsc': _alive('dsc_process'),
|
|
||||||
'morse': _alive('morse_process'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# WiFi
|
|
||||||
try:
|
|
||||||
from app import _get_wifi_health
|
|
||||||
|
|
||||||
wifi_active, _, _ = _get_wifi_health()
|
|
||||||
processes['wifi'] = wifi_active
|
|
||||||
except Exception:
|
|
||||||
processes['wifi'] = False
|
|
||||||
|
|
||||||
# Bluetooth
|
|
||||||
try:
|
|
||||||
from app import _get_bluetooth_health
|
|
||||||
|
|
||||||
bt_active, _ = _get_bluetooth_health()
|
|
||||||
processes['bluetooth'] = bt_active
|
|
||||||
except Exception:
|
|
||||||
processes['bluetooth'] = False
|
|
||||||
|
|
||||||
# SubGHz
|
|
||||||
try:
|
|
||||||
from app import _get_subghz_active
|
|
||||||
|
|
||||||
processes['subghz'] = _get_subghz_active()
|
|
||||||
except Exception:
|
|
||||||
processes['subghz'] = False
|
|
||||||
|
|
||||||
return processes
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_throttle_flags() -> str | None:
|
|
||||||
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['vcgencmd', 'get_throttled'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=2,
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and 'throttled=' in result.stdout:
|
|
||||||
return result.stdout.strip().split('=', 1)[1]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_power_draw() -> float | None:
|
|
||||||
"""Read power draw in watts from sysfs (Linux only)."""
|
|
||||||
try:
|
|
||||||
power_supply = Path('/sys/class/power_supply')
|
|
||||||
if not power_supply.exists():
|
|
||||||
return None
|
|
||||||
for supply_dir in power_supply.iterdir():
|
|
||||||
power_file = supply_dir / 'power_now'
|
|
||||||
if power_file.exists():
|
|
||||||
val = int(power_file.read_text().strip())
|
|
||||||
return round(val / 1_000_000, 2) # microwatts to watts
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_metrics() -> dict[str, Any]:
|
|
||||||
"""Gather a snapshot of system metrics."""
|
|
||||||
now = time.time()
|
|
||||||
start = _get_app_start_time()
|
|
||||||
uptime_seconds = round(now - start, 2)
|
|
||||||
|
|
||||||
metrics: dict[str, Any] = {
|
|
||||||
'type': 'system_metrics',
|
|
||||||
'timestamp': now,
|
|
||||||
'system': {
|
|
||||||
'hostname': socket.gethostname(),
|
|
||||||
'platform': platform.platform(),
|
|
||||||
'python': platform.python_version(),
|
|
||||||
'version': _get_app_version(),
|
|
||||||
'uptime_seconds': uptime_seconds,
|
|
||||||
'uptime_human': _format_uptime(uptime_seconds),
|
|
||||||
},
|
|
||||||
'processes': _collect_process_status(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if _HAS_PSUTIL:
|
|
||||||
# CPU — overall + per-core + frequency
|
|
||||||
cpu_percent = psutil.cpu_percent(interval=None)
|
|
||||||
cpu_count = psutil.cpu_count() or 1
|
|
||||||
try:
|
|
||||||
load_1, load_5, load_15 = os.getloadavg()
|
|
||||||
except (OSError, AttributeError):
|
|
||||||
load_1 = load_5 = load_15 = 0.0
|
|
||||||
|
|
||||||
per_core = []
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
per_core = psutil.cpu_percent(interval=None, percpu=True)
|
|
||||||
|
|
||||||
freq_data = None
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
freq = psutil.cpu_freq()
|
|
||||||
if freq:
|
|
||||||
freq_data = {
|
|
||||||
'current': round(freq.current, 0),
|
|
||||||
'min': round(freq.min, 0),
|
|
||||||
'max': round(freq.max, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics['cpu'] = {
|
|
||||||
'percent': cpu_percent,
|
|
||||||
'count': cpu_count,
|
|
||||||
'load_1': round(load_1, 2),
|
|
||||||
'load_5': round(load_5, 2),
|
|
||||||
'load_15': round(load_15, 2),
|
|
||||||
'per_core': per_core,
|
|
||||||
'freq': freq_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Memory
|
|
||||||
mem = psutil.virtual_memory()
|
|
||||||
metrics['memory'] = {
|
|
||||||
'total': mem.total,
|
|
||||||
'used': mem.used,
|
|
||||||
'available': mem.available,
|
|
||||||
'percent': mem.percent,
|
|
||||||
}
|
|
||||||
|
|
||||||
swap = psutil.swap_memory()
|
|
||||||
metrics['swap'] = {
|
|
||||||
'total': swap.total,
|
|
||||||
'used': swap.used,
|
|
||||||
'percent': swap.percent,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Disk — usage + I/O counters
|
|
||||||
try:
|
|
||||||
disk = psutil.disk_usage('/')
|
|
||||||
metrics['disk'] = {
|
|
||||||
'total': disk.total,
|
|
||||||
'used': disk.used,
|
|
||||||
'free': disk.free,
|
|
||||||
'percent': disk.percent,
|
|
||||||
'path': '/',
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
metrics['disk'] = None
|
|
||||||
|
|
||||||
disk_io = None
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
dio = psutil.disk_io_counters()
|
|
||||||
if dio:
|
|
||||||
disk_io = {
|
|
||||||
'read_bytes': dio.read_bytes,
|
|
||||||
'write_bytes': dio.write_bytes,
|
|
||||||
'read_count': dio.read_count,
|
|
||||||
'write_count': dio.write_count,
|
|
||||||
}
|
|
||||||
metrics['disk_io'] = disk_io
|
|
||||||
|
|
||||||
# Temperatures
|
|
||||||
try:
|
|
||||||
temps = psutil.sensors_temperatures()
|
|
||||||
if temps:
|
|
||||||
temp_data: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
for chip, entries in temps.items():
|
|
||||||
temp_data[chip] = [
|
|
||||||
{
|
|
||||||
'label': e.label or chip,
|
|
||||||
'current': e.current,
|
|
||||||
'high': e.high,
|
|
||||||
'critical': e.critical,
|
|
||||||
}
|
|
||||||
for e in entries
|
|
||||||
]
|
|
||||||
metrics['temperatures'] = temp_data
|
|
||||||
else:
|
|
||||||
metrics['temperatures'] = None
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
metrics['temperatures'] = None
|
|
||||||
|
|
||||||
# Fans
|
|
||||||
fans_data = None
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
fans = psutil.sensors_fans()
|
|
||||||
if fans:
|
|
||||||
fans_data = {}
|
|
||||||
for chip, entries in fans.items():
|
|
||||||
fans_data[chip] = [
|
|
||||||
{'label': e.label or chip, 'current': e.current}
|
|
||||||
for e in entries
|
|
||||||
]
|
|
||||||
metrics['fans'] = fans_data
|
|
||||||
|
|
||||||
# Battery
|
|
||||||
battery_data = None
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
bat = psutil.sensors_battery()
|
|
||||||
if bat:
|
|
||||||
battery_data = {
|
|
||||||
'percent': bat.percent,
|
|
||||||
'plugged': bat.power_plugged,
|
|
||||||
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
|
|
||||||
}
|
|
||||||
metrics['battery'] = battery_data
|
|
||||||
|
|
||||||
# Network interfaces
|
|
||||||
net_ifaces: list[dict[str, Any]] = []
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
addrs = psutil.net_if_addrs()
|
|
||||||
stats = psutil.net_if_stats()
|
|
||||||
for iface_name in sorted(addrs.keys()):
|
|
||||||
if iface_name == 'lo':
|
|
||||||
continue
|
|
||||||
iface_info: dict[str, Any] = {'name': iface_name}
|
|
||||||
# Get addresses
|
|
||||||
for addr in addrs[iface_name]:
|
|
||||||
if addr.family == socket.AF_INET:
|
|
||||||
iface_info['ipv4'] = addr.address
|
|
||||||
elif addr.family == socket.AF_INET6:
|
|
||||||
iface_info.setdefault('ipv6', addr.address)
|
|
||||||
elif addr.family == psutil.AF_LINK:
|
|
||||||
iface_info['mac'] = addr.address
|
|
||||||
# Get stats
|
|
||||||
if iface_name in stats:
|
|
||||||
st = stats[iface_name]
|
|
||||||
iface_info['is_up'] = st.isup
|
|
||||||
iface_info['speed'] = st.speed # Mbps
|
|
||||||
iface_info['mtu'] = st.mtu
|
|
||||||
net_ifaces.append(iface_info)
|
|
||||||
metrics['network'] = {'interfaces': net_ifaces}
|
|
||||||
|
|
||||||
# Network I/O counters (raw — JS computes deltas)
|
|
||||||
net_io = None
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
counters = psutil.net_io_counters(pernic=True)
|
|
||||||
if counters:
|
|
||||||
net_io = {}
|
|
||||||
for nic, c in counters.items():
|
|
||||||
if nic == 'lo':
|
|
||||||
continue
|
|
||||||
net_io[nic] = {
|
|
||||||
'bytes_sent': c.bytes_sent,
|
|
||||||
'bytes_recv': c.bytes_recv,
|
|
||||||
}
|
|
||||||
metrics['network']['io'] = net_io
|
|
||||||
|
|
||||||
# Connection count
|
|
||||||
conn_count = 0
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
conn_count = len(psutil.net_connections())
|
|
||||||
metrics['network']['connections'] = conn_count
|
|
||||||
|
|
||||||
# Boot time
|
|
||||||
boot_ts = None
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
boot_ts = psutil.boot_time()
|
|
||||||
metrics['boot_time'] = boot_ts
|
|
||||||
|
|
||||||
# Power / throttle (Pi-specific)
|
|
||||||
metrics['power'] = {
|
|
||||||
'throttled': _collect_throttle_flags(),
|
|
||||||
'draw_watts': _collect_power_draw(),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
metrics['cpu'] = None
|
|
||||||
metrics['memory'] = None
|
|
||||||
metrics['swap'] = None
|
|
||||||
metrics['disk'] = None
|
|
||||||
metrics['disk_io'] = None
|
|
||||||
metrics['temperatures'] = None
|
|
||||||
metrics['fans'] = None
|
|
||||||
metrics['battery'] = None
|
|
||||||
metrics['network'] = None
|
|
||||||
metrics['boot_time'] = None
|
|
||||||
metrics['power'] = None
|
|
||||||
|
|
||||||
return metrics
|
|
||||||
|
|
||||||
|
|
||||||
def _collector_loop() -> None:
|
|
||||||
"""Background thread that pushes metrics onto the queue every 3 seconds."""
|
|
||||||
# Seed psutil's CPU measurement so the first real read isn't 0%.
|
|
||||||
if _HAS_PSUTIL:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
psutil.cpu_percent(interval=None)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
metrics = _collect_metrics()
|
|
||||||
# Non-blocking put — drop oldest if full
|
|
||||||
try:
|
|
||||||
_metrics_queue.put_nowait(metrics)
|
|
||||||
except queue.Full:
|
|
||||||
with contextlib.suppress(queue.Empty):
|
|
||||||
_metrics_queue.get_nowait()
|
|
||||||
_metrics_queue.put_nowait(metrics)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug('system metrics collection error: %s', exc)
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_collector() -> None:
|
|
||||||
"""Start the background collector thread once."""
|
|
||||||
global _collector_started
|
|
||||||
if _collector_started:
|
|
||||||
return
|
|
||||||
with _collector_lock:
|
|
||||||
if _collector_started:
|
|
||||||
return
|
|
||||||
t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
|
|
||||||
t.start()
|
|
||||||
_collector_started = True
|
|
||||||
logger.info('System metrics collector started')
|
|
||||||
|
|
||||||
|
|
||||||
def _get_observer_location() -> dict[str, Any]:
|
|
||||||
"""Get observer location from GPS state or config defaults."""
|
|
||||||
lat, lon, source = None, None, 'none'
|
|
||||||
gps_meta: dict[str, Any] = {}
|
|
||||||
|
|
||||||
# Try GPS via utils.gps
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
from utils.gps import get_current_position
|
|
||||||
|
|
||||||
pos = get_current_position()
|
|
||||||
if pos and pos.fix_quality >= 2:
|
|
||||||
lat, lon, source = pos.latitude, pos.longitude, 'gps'
|
|
||||||
gps_meta['fix_quality'] = pos.fix_quality
|
|
||||||
gps_meta['satellites'] = pos.satellites
|
|
||||||
if pos.epx is not None and pos.epy is not None:
|
|
||||||
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
|
|
||||||
if pos.altitude is not None:
|
|
||||||
gps_meta['altitude'] = round(pos.altitude, 1)
|
|
||||||
|
|
||||||
# Fall back to config env vars
|
|
||||||
if lat is None:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
|
||||||
|
|
||||||
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
|
|
||||||
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
|
|
||||||
|
|
||||||
# Fall back to hardcoded constants (London)
|
|
||||||
if lat is None:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
|
|
||||||
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
|
|
||||||
|
|
||||||
lat, lon, source = CONST_LAT, CONST_LON, 'default'
|
|
||||||
|
|
||||||
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
|
|
||||||
if gps_meta:
|
|
||||||
result['gps'] = gps_meta
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Routes
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@system_bp.route('/metrics')
|
|
||||||
def get_metrics() -> Response:
|
|
||||||
"""REST snapshot of current system metrics."""
|
|
||||||
_ensure_collector()
|
|
||||||
return jsonify(_collect_metrics())
|
|
||||||
|
|
||||||
|
|
||||||
@system_bp.route('/stream')
|
|
||||||
def stream_system() -> Response:
|
|
||||||
"""SSE stream for real-time system metrics."""
|
|
||||||
_ensure_collector()
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=_metrics_queue,
|
|
||||||
channel_key='system',
|
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
)
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@system_bp.route('/sdr_devices')
|
|
||||||
def get_sdr_devices() -> Response:
|
|
||||||
"""Enumerate all connected SDR devices (on-demand, not every tick)."""
|
|
||||||
try:
|
|
||||||
from utils.sdr.detection import detect_all_devices
|
|
||||||
|
|
||||||
devices = detect_all_devices()
|
|
||||||
result = []
|
|
||||||
for d in devices:
|
|
||||||
result.append({
|
|
||||||
'type': d.sdr_type.value if hasattr(d.sdr_type, 'value') else str(d.sdr_type),
|
|
||||||
'index': d.index,
|
|
||||||
'name': d.name,
|
|
||||||
'serial': d.serial or '',
|
|
||||||
'driver': d.driver or '',
|
|
||||||
})
|
|
||||||
return jsonify({'devices': result})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning('SDR device detection failed: %s', exc)
|
|
||||||
return jsonify({'devices': [], 'error': str(exc)})
|
|
||||||
|
|
||||||
|
|
||||||
@system_bp.route('/location')
|
|
||||||
def get_location() -> Response:
|
|
||||||
"""Return observer location from GPS or config."""
|
|
||||||
return jsonify(_get_observer_location())
|
|
||||||
|
|
||||||
|
|
||||||
@system_bp.route('/weather')
|
|
||||||
def get_weather() -> Response:
|
|
||||||
"""Proxy weather from wttr.in, cached for 10 minutes."""
|
|
||||||
global _weather_cache, _weather_cache_time
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
|
|
||||||
return jsonify(_weather_cache)
|
|
||||||
|
|
||||||
lat = request.args.get('lat', type=float)
|
|
||||||
lon = request.args.get('lon', type=float)
|
|
||||||
if lat is None or lon is None:
|
|
||||||
loc = _get_observer_location()
|
|
||||||
lat, lon = loc.get('lat'), loc.get('lon')
|
|
||||||
|
|
||||||
if lat is None or lon is None:
|
|
||||||
return api_error('No location available')
|
|
||||||
|
|
||||||
if _requests is None:
|
|
||||||
return api_error('requests library not available')
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f'https://wttr.in/{lat},{lon}?format=j1',
|
|
||||||
timeout=5,
|
|
||||||
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
current = data.get('current_condition', [{}])[0]
|
|
||||||
weather = {
|
|
||||||
'temp_c': current.get('temp_C'),
|
|
||||||
'temp_f': current.get('temp_F'),
|
|
||||||
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
|
|
||||||
'humidity': current.get('humidity'),
|
|
||||||
'wind_mph': current.get('windspeedMiles'),
|
|
||||||
'wind_dir': current.get('winddir16Point'),
|
|
||||||
'feels_like_c': current.get('FeelsLikeC'),
|
|
||||||
'visibility': current.get('visibility'),
|
|
||||||
'pressure': current.get('pressure'),
|
|
||||||
}
|
|
||||||
_weather_cache = weather
|
|
||||||
_weather_cache_time = now
|
|
||||||
return jsonify(weather)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug('Weather fetch failed: %s', exc)
|
|
||||||
return api_error(str(exc))
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
"""
|
|
||||||
TSCM Baseline Routes
|
|
||||||
|
|
||||||
Handles /baseline/*, /baselines endpoints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
|
|
||||||
from routes.tscm import (
|
|
||||||
_baseline_recorder,
|
|
||||||
tscm_bp,
|
|
||||||
)
|
|
||||||
from utils.database import (
|
|
||||||
delete_tscm_baseline,
|
|
||||||
get_active_tscm_baseline,
|
|
||||||
get_all_tscm_baselines,
|
|
||||||
get_tscm_baseline,
|
|
||||||
get_tscm_sweep,
|
|
||||||
set_active_tscm_baseline,
|
|
||||||
)
|
|
||||||
from utils.tscm.baseline import (
|
|
||||||
get_comparison_for_active_baseline,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.tscm')
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/record', methods=['POST'])
|
|
||||||
def record_baseline():
|
|
||||||
"""Start recording a new baseline."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}')
|
|
||||||
location = data.get('location')
|
|
||||||
description = data.get('description')
|
|
||||||
|
|
||||||
baseline_id = _baseline_recorder.start_recording(name, location, description)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Baseline recording started',
|
|
||||||
'baseline_id': baseline_id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/stop', methods=['POST'])
|
|
||||||
def stop_baseline():
|
|
||||||
"""Stop baseline recording."""
|
|
||||||
result = _baseline_recorder.stop_recording()
|
|
||||||
|
|
||||||
if 'error' in result:
|
|
||||||
return jsonify({'status': 'error', 'message': result['error']})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Baseline recording complete',
|
|
||||||
**result
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/status')
|
|
||||||
def baseline_status():
|
|
||||||
"""Get baseline recording status."""
|
|
||||||
return jsonify(_baseline_recorder.get_recording_status())
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baselines')
|
|
||||||
def list_baselines():
|
|
||||||
"""List all baselines."""
|
|
||||||
baselines = get_all_tscm_baselines()
|
|
||||||
return jsonify({'status': 'success', 'baselines': baselines})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/<int:baseline_id>')
|
|
||||||
def get_baseline(baseline_id: int):
|
|
||||||
"""Get a specific baseline."""
|
|
||||||
baseline = get_tscm_baseline(baseline_id)
|
|
||||||
if not baseline:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'baseline': baseline})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/<int:baseline_id>/activate', methods=['POST'])
|
|
||||||
def activate_baseline(baseline_id: int):
|
|
||||||
"""Set a baseline as active."""
|
|
||||||
success = set_active_tscm_baseline(baseline_id)
|
|
||||||
if not success:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'message': 'Baseline activated'})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/<int:baseline_id>', methods=['DELETE'])
|
|
||||||
def remove_baseline(baseline_id: int):
|
|
||||||
"""Delete a baseline."""
|
|
||||||
success = delete_tscm_baseline(baseline_id)
|
|
||||||
if not success:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'message': 'Baseline deleted'})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/active')
|
|
||||||
def get_active_baseline():
|
|
||||||
"""Get the currently active baseline."""
|
|
||||||
baseline = get_active_tscm_baseline()
|
|
||||||
if not baseline:
|
|
||||||
return jsonify({'status': 'success', 'baseline': None})
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'baseline': baseline})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/compare', methods=['POST'])
|
|
||||||
def compare_against_baseline():
|
|
||||||
"""
|
|
||||||
Compare provided device data against the active baseline.
|
|
||||||
|
|
||||||
Expects JSON body with:
|
|
||||||
- wifi_devices: list of WiFi devices (optional)
|
|
||||||
- wifi_clients: list of WiFi clients (optional)
|
|
||||||
- bt_devices: list of Bluetooth devices (optional)
|
|
||||||
- rf_signals: list of RF signals (optional)
|
|
||||||
|
|
||||||
Returns comparison showing new, missing, and matching devices.
|
|
||||||
"""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
|
|
||||||
wifi_devices = data.get('wifi_devices')
|
|
||||||
wifi_clients = data.get('wifi_clients')
|
|
||||||
bt_devices = data.get('bt_devices')
|
|
||||||
rf_signals = data.get('rf_signals')
|
|
||||||
|
|
||||||
# Use the convenience function that gets active baseline
|
|
||||||
comparison = get_comparison_for_active_baseline(
|
|
||||||
wifi_devices=wifi_devices,
|
|
||||||
wifi_clients=wifi_clients,
|
|
||||||
bt_devices=bt_devices,
|
|
||||||
rf_signals=rf_signals
|
|
||||||
)
|
|
||||||
|
|
||||||
if comparison is None:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'No active baseline set'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'comparison': comparison
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Baseline Diff & Health Endpoints
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/diff/<int:baseline_id>/<int:sweep_id>')
|
|
||||||
def get_baseline_diff(baseline_id: int, sweep_id: int):
|
|
||||||
"""
|
|
||||||
Get comprehensive diff between a baseline and a sweep.
|
|
||||||
|
|
||||||
Shows new devices, missing devices, changed characteristics,
|
|
||||||
and baseline health assessment.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from utils.tscm.advanced import calculate_baseline_diff
|
|
||||||
|
|
||||||
baseline = get_tscm_baseline(baseline_id)
|
|
||||||
if not baseline:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
|
|
||||||
|
|
||||||
sweep = get_tscm_sweep(sweep_id)
|
|
||||||
if not sweep:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404
|
|
||||||
|
|
||||||
# Get current devices from sweep results
|
|
||||||
results = sweep.get('results', {})
|
|
||||||
if isinstance(results, str):
|
|
||||||
results = json.loads(results)
|
|
||||||
|
|
||||||
current_wifi = results.get('wifi_devices', [])
|
|
||||||
current_wifi_clients = results.get('wifi_clients', [])
|
|
||||||
current_bt = results.get('bt_devices', [])
|
|
||||||
current_rf = results.get('rf_signals', [])
|
|
||||||
|
|
||||||
diff = calculate_baseline_diff(
|
|
||||||
baseline=baseline,
|
|
||||||
current_wifi=current_wifi,
|
|
||||||
current_wifi_clients=current_wifi_clients,
|
|
||||||
current_bt=current_bt,
|
|
||||||
current_rf=current_rf,
|
|
||||||
sweep_id=sweep_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'diff': diff.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Get baseline diff error: {e}")
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/baseline/<int:baseline_id>/health')
|
|
||||||
def get_baseline_health(baseline_id: int):
|
|
||||||
"""Get health assessment for a baseline."""
|
|
||||||
try:
|
|
||||||
|
|
||||||
baseline = get_tscm_baseline(baseline_id)
|
|
||||||
if not baseline:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
|
|
||||||
|
|
||||||
# Calculate age
|
|
||||||
created_at = baseline.get('created_at')
|
|
||||||
age_hours = 0
|
|
||||||
if created_at:
|
|
||||||
if isinstance(created_at, str):
|
|
||||||
created = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
|
||||||
age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600
|
|
||||||
elif isinstance(created_at, datetime):
|
|
||||||
age_hours = (datetime.now() - created_at).total_seconds() / 3600
|
|
||||||
|
|
||||||
# Count devices
|
|
||||||
total_devices = (
|
|
||||||
len(baseline.get('wifi_networks', [])) +
|
|
||||||
len(baseline.get('bt_devices', [])) +
|
|
||||||
len(baseline.get('rf_frequencies', []))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Determine health
|
|
||||||
health = 'healthy'
|
|
||||||
score = 1.0
|
|
||||||
reasons = []
|
|
||||||
|
|
||||||
if age_hours > 168:
|
|
||||||
health = 'stale'
|
|
||||||
score = 0.3
|
|
||||||
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 1 week)')
|
|
||||||
elif age_hours > 72:
|
|
||||||
health = 'noisy'
|
|
||||||
score = 0.6
|
|
||||||
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 3 days)')
|
|
||||||
|
|
||||||
if total_devices < 3:
|
|
||||||
score -= 0.2
|
|
||||||
reasons.append(f'Baseline has few devices ({total_devices})')
|
|
||||||
if health == 'healthy':
|
|
||||||
health = 'noisy'
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'health': {
|
|
||||||
'status': health,
|
|
||||||
'score': round(max(0, score), 2),
|
|
||||||
'age_hours': round(age_hours, 1),
|
|
||||||
'total_devices': total_devices,
|
|
||||||
'reasons': reasons,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Get baseline health error: {e}")
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"""
|
|
||||||
TSCM Case Management Routes
|
|
||||||
|
|
||||||
Handles /cases/* endpoints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
|
|
||||||
from routes.tscm import tscm_bp
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.tscm')
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/cases', methods=['GET'])
|
|
||||||
def list_cases():
|
|
||||||
"""List all TSCM cases."""
|
|
||||||
from utils.database import get_all_tscm_cases
|
|
||||||
|
|
||||||
status = request.args.get('status')
|
|
||||||
limit = request.args.get('limit', 50, type=int)
|
|
||||||
|
|
||||||
cases = get_all_tscm_cases(status=status, limit=limit)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'count': len(cases),
|
|
||||||
'cases': cases
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/cases', methods=['POST'])
|
|
||||||
def create_case():
|
|
||||||
"""Create a new TSCM case."""
|
|
||||||
from utils.database import create_tscm_case
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
|
|
||||||
name = data.get('name')
|
|
||||||
if not name:
|
|
||||||
return jsonify({'status': 'error', 'message': 'name is required'}), 400
|
|
||||||
|
|
||||||
case_id = create_tscm_case(
|
|
||||||
name=name,
|
|
||||||
description=data.get('description'),
|
|
||||||
location=data.get('location'),
|
|
||||||
priority=data.get('priority', 'normal'),
|
|
||||||
created_by=data.get('created_by'),
|
|
||||||
metadata=data.get('metadata')
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Case created',
|
|
||||||
'case_id': case_id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/cases/<int:case_id>', methods=['GET'])
|
|
||||||
def get_case(case_id: int):
|
|
||||||
"""Get a TSCM case with all linked sweeps, threats, and notes."""
|
|
||||||
from utils.database import get_tscm_case
|
|
||||||
|
|
||||||
case = get_tscm_case(case_id)
|
|
||||||
if not case:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'case': case
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/cases/<int:case_id>', methods=['PUT'])
|
|
||||||
def update_case(case_id: int):
|
|
||||||
"""Update a TSCM case."""
|
|
||||||
from utils.database import update_tscm_case
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
|
|
||||||
success = update_tscm_case(
|
|
||||||
case_id=case_id,
|
|
||||||
status=data.get('status'),
|
|
||||||
priority=data.get('priority'),
|
|
||||||
assigned_to=data.get('assigned_to'),
|
|
||||||
notes=data.get('notes')
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Case updated'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/cases/<int:case_id>/sweeps/<int:sweep_id>', methods=['POST'])
|
|
||||||
def link_sweep_to_case(case_id: int, sweep_id: int):
|
|
||||||
"""Link a sweep to a case."""
|
|
||||||
from utils.database import add_sweep_to_case
|
|
||||||
|
|
||||||
success = add_sweep_to_case(case_id, sweep_id)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success' if success else 'error',
|
|
||||||
'message': 'Sweep linked to case' if success else 'Already linked or not found'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/cases/<int:case_id>/threats/<int:threat_id>', methods=['POST'])
|
|
||||||
def link_threat_to_case(case_id: int, threat_id: int):
|
|
||||||
"""Link a threat to a case."""
|
|
||||||
from utils.database import add_threat_to_case
|
|
||||||
|
|
||||||
success = add_threat_to_case(case_id, threat_id)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success' if success else 'error',
|
|
||||||
'message': 'Threat linked to case' if success else 'Already linked or not found'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/cases/<int:case_id>/notes', methods=['POST'])
|
|
||||||
def add_note_to_case(case_id: int):
|
|
||||||
"""Add a note to a case."""
|
|
||||||
from utils.database import add_case_note
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
|
|
||||||
content = data.get('content')
|
|
||||||
if not content:
|
|
||||||
return jsonify({'status': 'error', 'message': 'content is required'}), 400
|
|
||||||
|
|
||||||
note_id = add_case_note(
|
|
||||||
case_id=case_id,
|
|
||||||
content=content,
|
|
||||||
note_type=data.get('note_type', 'general'),
|
|
||||||
created_by=data.get('created_by')
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Note added',
|
|
||||||
'note_id': note_id
|
|
||||||
})
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""
|
|
||||||
TSCM Meeting Window Routes
|
|
||||||
|
|
||||||
Handles /meeting/* endpoints for time correlation during sensitive periods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
|
|
||||||
from routes.tscm import (
|
|
||||||
_current_sweep_id,
|
|
||||||
_emit_event,
|
|
||||||
tscm_bp,
|
|
||||||
)
|
|
||||||
from utils.tscm.correlation import get_correlation_engine
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.tscm')
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/meeting/start', methods=['POST'])
|
|
||||||
def start_meeting():
|
|
||||||
"""
|
|
||||||
Mark the start of a sensitive period (meeting, briefing, etc.).
|
|
||||||
|
|
||||||
Devices detected during this window will receive additional scoring
|
|
||||||
for meeting-correlated activity.
|
|
||||||
"""
|
|
||||||
correlation = get_correlation_engine()
|
|
||||||
correlation.start_meeting_window()
|
|
||||||
|
|
||||||
_emit_event('meeting_started', {
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'message': 'Sensitive period monitoring active'
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Meeting window started - devices detected now will be flagged'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/meeting/end', methods=['POST'])
|
|
||||||
def end_meeting():
|
|
||||||
"""Mark the end of a sensitive period."""
|
|
||||||
correlation = get_correlation_engine()
|
|
||||||
correlation.end_meeting_window()
|
|
||||||
|
|
||||||
_emit_event('meeting_ended', {
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Meeting window ended'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/meeting/status')
|
|
||||||
def meeting_status():
|
|
||||||
"""Check if currently in a meeting window."""
|
|
||||||
correlation = get_correlation_engine()
|
|
||||||
in_meeting = correlation.is_during_meeting()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'in_meeting': in_meeting,
|
|
||||||
'windows': [
|
|
||||||
{
|
|
||||||
'start': start.isoformat(),
|
|
||||||
'end': end.isoformat() if end else None
|
|
||||||
}
|
|
||||||
for start, end in correlation.meeting_windows
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Meeting Window Enhanced Endpoints
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@tscm_bp.route('/meeting/start-tracked', methods=['POST'])
|
|
||||||
def start_tracked_meeting():
|
|
||||||
"""
|
|
||||||
Start a tracked meeting window with database persistence.
|
|
||||||
|
|
||||||
Tracks devices first seen during meeting and behavior changes.
|
|
||||||
"""
|
|
||||||
from utils.database import start_meeting_window
|
|
||||||
from utils.tscm.advanced import get_timeline_manager
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
|
|
||||||
meeting_id = start_meeting_window(
|
|
||||||
sweep_id=_current_sweep_id,
|
|
||||||
name=data.get('name'),
|
|
||||||
location=data.get('location'),
|
|
||||||
notes=data.get('notes')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start meeting in correlation engine
|
|
||||||
correlation = get_correlation_engine()
|
|
||||||
correlation.start_meeting_window()
|
|
||||||
|
|
||||||
# Start in timeline manager
|
|
||||||
manager = get_timeline_manager()
|
|
||||||
manager.start_meeting_window()
|
|
||||||
|
|
||||||
_emit_event('meeting_started', {
|
|
||||||
'meeting_id': meeting_id,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'name': data.get('name'),
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Tracked meeting window started',
|
|
||||||
'meeting_id': meeting_id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/meeting/<int:meeting_id>/end', methods=['POST'])
|
|
||||||
def end_tracked_meeting(meeting_id: int):
|
|
||||||
"""End a tracked meeting window."""
|
|
||||||
from utils.database import end_meeting_window
|
|
||||||
from utils.tscm.advanced import get_timeline_manager
|
|
||||||
|
|
||||||
success = end_meeting_window(meeting_id)
|
|
||||||
if not success:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Meeting not found or already ended'}), 404
|
|
||||||
|
|
||||||
# End in correlation engine
|
|
||||||
correlation = get_correlation_engine()
|
|
||||||
correlation.end_meeting_window()
|
|
||||||
|
|
||||||
# End in timeline manager
|
|
||||||
manager = get_timeline_manager()
|
|
||||||
manager.end_meeting_window()
|
|
||||||
|
|
||||||
_emit_event('meeting_ended', {
|
|
||||||
'meeting_id': meeting_id,
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Meeting window ended'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/meeting/<int:meeting_id>/summary')
|
|
||||||
def get_meeting_summary_endpoint(meeting_id: int):
|
|
||||||
"""Get detailed summary of device activity during a meeting."""
|
|
||||||
try:
|
|
||||||
from routes.tscm import _current_sweep_id
|
|
||||||
from utils.database import get_meeting_windows
|
|
||||||
from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager
|
|
||||||
|
|
||||||
# Get meeting window
|
|
||||||
windows = get_meeting_windows(_current_sweep_id or 0)
|
|
||||||
meeting = None
|
|
||||||
for w in windows:
|
|
||||||
if w.get('id') == meeting_id:
|
|
||||||
meeting = w
|
|
||||||
break
|
|
||||||
|
|
||||||
if not meeting:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Meeting not found'}), 404
|
|
||||||
|
|
||||||
# Get timelines and profiles
|
|
||||||
manager = get_timeline_manager()
|
|
||||||
timelines = manager.get_all_timelines()
|
|
||||||
|
|
||||||
correlation = get_correlation_engine()
|
|
||||||
profiles = [p.to_dict() for p in correlation.device_profiles.values()]
|
|
||||||
|
|
||||||
summary = generate_meeting_summary(meeting, timelines, profiles)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'summary': summary.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Get meeting summary error: {e}")
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/meeting/active')
|
|
||||||
def get_active_meeting():
|
|
||||||
"""Get currently active meeting window."""
|
|
||||||
from utils.database import get_active_meeting_window
|
|
||||||
|
|
||||||
meeting = get_active_meeting_window(_current_sweep_id)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'meeting': meeting,
|
|
||||||
'is_active': meeting is not None
|
|
||||||
})
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
"""
|
|
||||||
TSCM Schedule Routes
|
|
||||||
|
|
||||||
Handles /schedules/* endpoints for automated sweep scheduling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
|
|
||||||
from routes.tscm import (
|
|
||||||
_get_schedule_timezone,
|
|
||||||
_next_run_from_cron,
|
|
||||||
_start_sweep_internal,
|
|
||||||
tscm_bp,
|
|
||||||
)
|
|
||||||
from utils.database import (
|
|
||||||
create_tscm_schedule,
|
|
||||||
delete_tscm_schedule,
|
|
||||||
get_all_tscm_schedules,
|
|
||||||
get_tscm_schedule,
|
|
||||||
update_tscm_schedule,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.tscm')
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/schedules', methods=['GET'])
|
|
||||||
def list_schedules():
|
|
||||||
"""List all TSCM sweep schedules."""
|
|
||||||
enabled_param = request.args.get('enabled')
|
|
||||||
enabled = None
|
|
||||||
if enabled_param is not None:
|
|
||||||
enabled = enabled_param.lower() in ('1', 'true', 'yes')
|
|
||||||
|
|
||||||
schedules = get_all_tscm_schedules(enabled=enabled, limit=200)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'count': len(schedules),
|
|
||||||
'schedules': schedules,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/schedules', methods=['POST'])
|
|
||||||
def create_schedule():
|
|
||||||
"""Create a new sweep schedule."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
name = (data.get('name') or '').strip()
|
|
||||||
cron_expression = (data.get('cron_expression') or '').strip()
|
|
||||||
sweep_type = data.get('sweep_type', 'standard')
|
|
||||||
baseline_id = data.get('baseline_id')
|
|
||||||
zone_name = data.get('zone_name')
|
|
||||||
enabled = bool(data.get('enabled', True))
|
|
||||||
notify_on_threat = bool(data.get('notify_on_threat', True))
|
|
||||||
notify_email = data.get('notify_email')
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Schedule name required'}), 400
|
|
||||||
if not cron_expression:
|
|
||||||
return jsonify({'status': 'error', 'message': 'cron_expression required'}), 400
|
|
||||||
|
|
||||||
next_run = None
|
|
||||||
if enabled:
|
|
||||||
try:
|
|
||||||
tz = _get_schedule_timezone(zone_name)
|
|
||||||
next_local = _next_run_from_cron(cron_expression, datetime.now(tz))
|
|
||||||
next_run = next_local.astimezone(timezone.utc).isoformat() if next_local else None
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
|
|
||||||
|
|
||||||
schedule_id = create_tscm_schedule(
|
|
||||||
name=name,
|
|
||||||
cron_expression=cron_expression,
|
|
||||||
sweep_type=sweep_type,
|
|
||||||
baseline_id=baseline_id,
|
|
||||||
zone_name=zone_name,
|
|
||||||
enabled=enabled,
|
|
||||||
notify_on_threat=notify_on_threat,
|
|
||||||
notify_email=notify_email,
|
|
||||||
next_run=next_run,
|
|
||||||
)
|
|
||||||
schedule = get_tscm_schedule(schedule_id)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': 'Schedule created',
|
|
||||||
'schedule': schedule
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['PUT', 'PATCH'])
|
|
||||||
def update_schedule(schedule_id: int):
|
|
||||||
"""Update a sweep schedule."""
|
|
||||||
schedule = get_tscm_schedule(schedule_id)
|
|
||||||
if not schedule:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
updates: dict[str, Any] = {}
|
|
||||||
|
|
||||||
for key in ('name', 'cron_expression', 'sweep_type', 'baseline_id', 'zone_name', 'notify_email'):
|
|
||||||
if key in data:
|
|
||||||
updates[key] = data[key]
|
|
||||||
|
|
||||||
if 'baseline_id' in updates and updates['baseline_id'] in ('', None):
|
|
||||||
updates['baseline_id'] = None
|
|
||||||
|
|
||||||
if 'enabled' in data:
|
|
||||||
updates['enabled'] = 1 if data['enabled'] else 0
|
|
||||||
if 'notify_on_threat' in data:
|
|
||||||
updates['notify_on_threat'] = 1 if data['notify_on_threat'] else 0
|
|
||||||
|
|
||||||
# Recalculate next_run when cron/zone/enabled changes
|
|
||||||
if any(k in updates for k in ('cron_expression', 'zone_name', 'enabled')):
|
|
||||||
if updates.get('enabled', schedule.get('enabled', 1)):
|
|
||||||
cron_expr = updates.get('cron_expression', schedule.get('cron_expression', ''))
|
|
||||||
zone_name = updates.get('zone_name', schedule.get('zone_name'))
|
|
||||||
try:
|
|
||||||
tz = _get_schedule_timezone(zone_name)
|
|
||||||
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
|
|
||||||
updates['next_run'] = next_local.astimezone(timezone.utc).isoformat() if next_local else None
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
|
|
||||||
else:
|
|
||||||
updates['next_run'] = None
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No updates provided'}), 400
|
|
||||||
|
|
||||||
update_tscm_schedule(schedule_id, **updates)
|
|
||||||
schedule = get_tscm_schedule(schedule_id)
|
|
||||||
return jsonify({'status': 'success', 'schedule': schedule})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['DELETE'])
|
|
||||||
def delete_schedule(schedule_id: int):
|
|
||||||
"""Delete a sweep schedule."""
|
|
||||||
success = delete_tscm_schedule(schedule_id)
|
|
||||||
if not success:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
|
|
||||||
return jsonify({'status': 'success', 'message': 'Schedule deleted'})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/schedules/<int:schedule_id>/run', methods=['POST'])
|
|
||||||
def run_schedule_now(schedule_id: int):
|
|
||||||
"""Trigger a scheduled sweep immediately."""
|
|
||||||
schedule = get_tscm_schedule(schedule_id)
|
|
||||||
if not schedule:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
|
|
||||||
|
|
||||||
result = _start_sweep_internal(
|
|
||||||
sweep_type=schedule.get('sweep_type') or 'standard',
|
|
||||||
baseline_id=schedule.get('baseline_id'),
|
|
||||||
wifi_enabled=True,
|
|
||||||
bt_enabled=True,
|
|
||||||
rf_enabled=True,
|
|
||||||
wifi_interface='',
|
|
||||||
bt_interface='',
|
|
||||||
sdr_device=None,
|
|
||||||
verbose_results=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.get('status') != 'success':
|
|
||||||
status_code = result.pop('http_status', 400)
|
|
||||||
return jsonify(result), status_code
|
|
||||||
|
|
||||||
# Update schedule run timestamps
|
|
||||||
cron_expr = schedule.get('cron_expression') or ''
|
|
||||||
tz = _get_schedule_timezone(schedule.get('zone_name'))
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
|
||||||
try:
|
|
||||||
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
|
|
||||||
except Exception:
|
|
||||||
next_local = None
|
|
||||||
|
|
||||||
update_tscm_schedule(
|
|
||||||
schedule_id,
|
|
||||||
last_run=now_utc.isoformat(),
|
|
||||||
next_run=next_local.astimezone(timezone.utc).isoformat() if next_local else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(result)
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
"""
|
|
||||||
TSCM Sweep Routes
|
|
||||||
|
|
||||||
Handles /sweep/*, /status, /devices, /presets/*, /feed/*,
|
|
||||||
/capabilities, and /sweep/<id>/capabilities endpoints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Response, jsonify, request
|
|
||||||
|
|
||||||
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
|
|
||||||
from routes.tscm import (
|
|
||||||
_baseline_recorder,
|
|
||||||
_current_sweep_id,
|
|
||||||
_emit_event,
|
|
||||||
_start_sweep_internal,
|
|
||||||
_sweep_running,
|
|
||||||
tscm_bp,
|
|
||||||
tscm_queue,
|
|
||||||
)
|
|
||||||
from utils.database import get_tscm_sweep, update_tscm_sweep
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.tscm')
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/status')
|
|
||||||
def tscm_status():
|
|
||||||
"""Check if any TSCM operation is currently running."""
|
|
||||||
return jsonify({'running': _sweep_running})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/start', methods=['POST'])
|
|
||||||
def start_sweep():
|
|
||||||
"""Start a TSCM sweep."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
sweep_type = data.get('sweep_type', 'standard')
|
|
||||||
baseline_id = data.get('baseline_id')
|
|
||||||
if baseline_id in ('', None):
|
|
||||||
baseline_id = None
|
|
||||||
wifi_enabled = data.get('wifi', True)
|
|
||||||
bt_enabled = data.get('bluetooth', True)
|
|
||||||
rf_enabled = data.get('rf', True)
|
|
||||||
verbose_results = bool(data.get('verbose_results', False))
|
|
||||||
|
|
||||||
# Get interface selections
|
|
||||||
wifi_interface = data.get('wifi_interface', '')
|
|
||||||
bt_interface = data.get('bt_interface', '')
|
|
||||||
sdr_device = data.get('sdr_device')
|
|
||||||
|
|
||||||
result = _start_sweep_internal(
|
|
||||||
sweep_type=sweep_type,
|
|
||||||
baseline_id=baseline_id,
|
|
||||||
wifi_enabled=wifi_enabled,
|
|
||||||
bt_enabled=bt_enabled,
|
|
||||||
rf_enabled=rf_enabled,
|
|
||||||
wifi_interface=wifi_interface,
|
|
||||||
bt_interface=bt_interface,
|
|
||||||
sdr_device=sdr_device,
|
|
||||||
verbose_results=verbose_results,
|
|
||||||
)
|
|
||||||
http_status = result.pop('http_status', 200)
|
|
||||||
return jsonify(result), http_status
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/stop', methods=['POST'])
|
|
||||||
def stop_sweep():
|
|
||||||
"""Stop the current TSCM sweep."""
|
|
||||||
import routes.tscm as _tscm_pkg
|
|
||||||
|
|
||||||
if not _tscm_pkg._sweep_running:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No sweep running'})
|
|
||||||
|
|
||||||
_tscm_pkg._sweep_running = False
|
|
||||||
|
|
||||||
if _tscm_pkg._current_sweep_id:
|
|
||||||
update_tscm_sweep(_tscm_pkg._current_sweep_id, status='aborted', completed=True)
|
|
||||||
|
|
||||||
_emit_event('sweep_stopped', {'reason': 'user_requested'})
|
|
||||||
|
|
||||||
logger.info("TSCM sweep stopped by user")
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'message': 'Sweep stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/status')
|
|
||||||
def sweep_status():
|
|
||||||
"""Get current sweep status."""
|
|
||||||
|
|
||||||
status = {
|
|
||||||
'running': _sweep_running,
|
|
||||||
'sweep_id': _current_sweep_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _current_sweep_id:
|
|
||||||
sweep = get_tscm_sweep(_current_sweep_id)
|
|
||||||
if sweep:
|
|
||||||
status['sweep'] = sweep
|
|
||||||
|
|
||||||
return jsonify(status)
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/stream')
|
|
||||||
def sweep_stream():
|
|
||||||
"""SSE stream for real-time sweep updates."""
|
|
||||||
|
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
|
||||||
process_event('tscm', msg, msg.get('type'))
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=tscm_queue,
|
|
||||||
channel_key='tscm',
|
|
||||||
timeout=1.0,
|
|
||||||
keepalive_interval=30.0,
|
|
||||||
on_message=_on_msg,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
headers={
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/devices')
|
|
||||||
def get_tscm_devices():
|
|
||||||
"""Get available scanning devices for TSCM sweeps."""
|
|
||||||
devices = {
|
|
||||||
'wifi_interfaces': [],
|
|
||||||
'bt_adapters': [],
|
|
||||||
'sdr_devices': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Detect WiFi interfaces
|
|
||||||
if platform.system() == 'Darwin': # macOS
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['networksetup', '-listallhardwareports'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
lines = result.stdout.split('\n')
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
if 'Wi-Fi' in line or 'AirPort' in line:
|
|
||||||
# Get the hardware port name (e.g., "Wi-Fi")
|
|
||||||
port_name = line.replace('Hardware Port:', '').strip()
|
|
||||||
for j in range(i + 1, min(i + 3, len(lines))):
|
|
||||||
if 'Device:' in lines[j]:
|
|
||||||
device = lines[j].split('Device:')[1].strip()
|
|
||||||
devices['wifi_interfaces'].append({
|
|
||||||
'name': device,
|
|
||||||
'display_name': f'{port_name} ({device})',
|
|
||||||
'type': 'internal',
|
|
||||||
'monitor_capable': False
|
|
||||||
})
|
|
||||||
break
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
||||||
pass
|
|
||||||
else: # Linux
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['iw', 'dev'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
current_iface = None
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith('Interface'):
|
|
||||||
current_iface = line.split()[1]
|
|
||||||
elif current_iface and 'type' in line:
|
|
||||||
iface_type = line.split()[-1]
|
|
||||||
devices['wifi_interfaces'].append({
|
|
||||||
'name': current_iface,
|
|
||||||
'display_name': f'Wireless ({current_iface}) - {iface_type}',
|
|
||||||
'type': iface_type,
|
|
||||||
'monitor_capable': True
|
|
||||||
})
|
|
||||||
current_iface = None
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
||||||
# Fall back to iwconfig
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['iwconfig'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if 'IEEE 802.11' in line:
|
|
||||||
iface = line.split()[0]
|
|
||||||
devices['wifi_interfaces'].append({
|
|
||||||
'name': iface,
|
|
||||||
'display_name': f'Wireless ({iface})',
|
|
||||||
'type': 'managed',
|
|
||||||
'monitor_capable': True
|
|
||||||
})
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Detect Bluetooth adapters
|
|
||||||
if platform.system() == 'Linux':
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['hciconfig'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
|
||||||
for _idx, block in enumerate(blocks):
|
|
||||||
if block.strip():
|
|
||||||
first_line = block.split('\n')[0]
|
|
||||||
match = re.match(r'(hci\d+):', first_line)
|
|
||||||
if match:
|
|
||||||
iface_name = match.group(1)
|
|
||||||
is_up = 'UP RUNNING' in block or '\tUP ' in block
|
|
||||||
devices['bt_adapters'].append({
|
|
||||||
'name': iface_name,
|
|
||||||
'display_name': f'Bluetooth Adapter ({iface_name})',
|
|
||||||
'type': 'hci',
|
|
||||||
'status': 'up' if is_up else 'down'
|
|
||||||
})
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
||||||
# Try bluetoothctl as fallback
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['bluetoothctl', 'list'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if 'Controller' in line:
|
|
||||||
# Format: Controller XX:XX:XX:XX:XX:XX Name
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 3:
|
|
||||||
addr = parts[1]
|
|
||||||
name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth'
|
|
||||||
devices['bt_adapters'].append({
|
|
||||||
'name': addr,
|
|
||||||
'display_name': f'{name} ({addr[-8:]})',
|
|
||||||
'type': 'controller',
|
|
||||||
'status': 'available'
|
|
||||||
})
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
||||||
pass
|
|
||||||
elif platform.system() == 'Darwin':
|
|
||||||
# macOS has built-in Bluetooth - get more info via system_profiler
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['system_profiler', 'SPBluetoothDataType'],
|
|
||||||
capture_output=True, text=True, timeout=10
|
|
||||||
)
|
|
||||||
# Extract controller info
|
|
||||||
bt_name = 'Built-in Bluetooth'
|
|
||||||
bt_addr = ''
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if 'Address:' in line:
|
|
||||||
bt_addr = line.split('Address:')[1].strip()
|
|
||||||
break
|
|
||||||
devices['bt_adapters'].append({
|
|
||||||
'name': 'default',
|
|
||||||
'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''),
|
|
||||||
'type': 'macos',
|
|
||||||
'status': 'available'
|
|
||||||
})
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
||||||
devices['bt_adapters'].append({
|
|
||||||
'name': 'default',
|
|
||||||
'display_name': 'Built-in Bluetooth',
|
|
||||||
'type': 'macos',
|
|
||||||
'status': 'available'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Detect SDR devices
|
|
||||||
try:
|
|
||||||
from utils.sdr import SDRFactory
|
|
||||||
sdr_list = SDRFactory.detect_devices()
|
|
||||||
for sdr in sdr_list:
|
|
||||||
# SDRDevice is a dataclass with attributes, not a dict
|
|
||||||
sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, 'value') else str(sdr.sdr_type)
|
|
||||||
# Create a friendly display name
|
|
||||||
display_name = sdr.name
|
|
||||||
if sdr.serial and sdr.serial not in ('N/A', 'Unknown'):
|
|
||||||
display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})'
|
|
||||||
devices['sdr_devices'].append({
|
|
||||||
'index': sdr.index,
|
|
||||||
'name': sdr.name,
|
|
||||||
'display_name': display_name,
|
|
||||||
'type': sdr_type_name,
|
|
||||||
'serial': sdr.serial,
|
|
||||||
'driver': sdr.driver
|
|
||||||
})
|
|
||||||
except ImportError:
|
|
||||||
logger.debug("SDR module not available")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error detecting SDR devices: {e}")
|
|
||||||
|
|
||||||
# Check if running as root
|
|
||||||
from flask import current_app
|
|
||||||
running_as_root = current_app.config.get('RUNNING_AS_ROOT', os.geteuid() == 0)
|
|
||||||
|
|
||||||
warnings = []
|
|
||||||
if not running_as_root:
|
|
||||||
warnings.append({
|
|
||||||
'type': 'privileges',
|
|
||||||
'message': 'Not running as root. WiFi monitor mode and some Bluetooth features require sudo.',
|
|
||||||
'action': 'Run with: sudo -E venv/bin/python intercept.py'
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'devices': devices,
|
|
||||||
'running_as_root': running_as_root,
|
|
||||||
'warnings': warnings
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Preset Endpoints
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@tscm_bp.route('/presets')
|
|
||||||
def list_presets():
|
|
||||||
"""List available sweep presets."""
|
|
||||||
presets = get_all_sweep_presets()
|
|
||||||
return jsonify({'status': 'success', 'presets': presets})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/presets/<preset_name>')
|
|
||||||
def get_preset(preset_name: str):
|
|
||||||
"""Get details for a specific preset."""
|
|
||||||
preset = get_sweep_preset(preset_name)
|
|
||||||
if not preset:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Preset not found'}), 404
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'preset': preset})
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Data Feed Endpoints (for adding data during sweeps/baselines)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@tscm_bp.route('/feed/wifi', methods=['POST'])
|
|
||||||
def feed_wifi():
|
|
||||||
"""Feed WiFi device data for baseline recording."""
|
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
if data:
|
|
||||||
if data.get('is_client'):
|
|
||||||
_baseline_recorder.add_wifi_client(data)
|
|
||||||
else:
|
|
||||||
_baseline_recorder.add_wifi_device(data)
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/feed/bluetooth', methods=['POST'])
|
|
||||||
def feed_bluetooth():
|
|
||||||
"""Feed Bluetooth device data for baseline recording."""
|
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
if data:
|
|
||||||
_baseline_recorder.add_bt_device(data)
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/feed/rf', methods=['POST'])
|
|
||||||
def feed_rf():
|
|
||||||
"""Feed RF signal data for baseline recording."""
|
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
if data:
|
|
||||||
_baseline_recorder.add_rf_signal(data)
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Capabilities & Coverage Endpoints
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@tscm_bp.route('/capabilities')
|
|
||||||
def get_capabilities():
|
|
||||||
"""
|
|
||||||
Get current system capabilities for TSCM sweeping.
|
|
||||||
|
|
||||||
Returns what the system CAN and CANNOT detect based on OS,
|
|
||||||
privileges, adapters, and SDR hardware.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from utils.tscm.advanced import detect_sweep_capabilities
|
|
||||||
|
|
||||||
wifi_interface = request.args.get('wifi_interface', '')
|
|
||||||
bt_adapter = request.args.get('bt_adapter', '')
|
|
||||||
|
|
||||||
caps = detect_sweep_capabilities(
|
|
||||||
wifi_interface=wifi_interface,
|
|
||||||
bt_adapter=bt_adapter
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'capabilities': caps.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Get capabilities error: {e}")
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/<int:sweep_id>/capabilities')
|
|
||||||
def get_sweep_stored_capabilities(sweep_id: int):
|
|
||||||
"""Get stored capabilities for a specific sweep."""
|
|
||||||
from utils.database import get_sweep_capabilities
|
|
||||||
|
|
||||||
caps = get_sweep_capabilities(sweep_id)
|
|
||||||
if not caps:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No capabilities stored for this sweep'}), 404
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'capabilities': caps
|
|
||||||
})
|
|
||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.updater import (
|
from utils.updater import (
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
dismiss_update,
|
dismiss_update,
|
||||||
@@ -40,7 +39,10 @@ def check_updates() -> Response:
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking for updates: {e}")
|
logger.error(f"Error checking for updates: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/status', methods=['GET'])
|
@updater_bp.route('/status', methods=['GET'])
|
||||||
@@ -59,7 +61,10 @@ def update_status() -> Response:
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting update status: {e}")
|
logger.error(f"Error getting update status: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/update', methods=['POST'])
|
@updater_bp.route('/update', methods=['POST'])
|
||||||
@@ -95,7 +100,10 @@ def do_update() -> Response:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error performing update: {e}")
|
logger.error(f"Error performing update: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/dismiss', methods=['POST'])
|
@updater_bp.route('/dismiss', methods=['POST'])
|
||||||
@@ -116,14 +124,20 @@ def dismiss_notification() -> Response:
|
|||||||
version = data.get('version')
|
version = data.get('version')
|
||||||
|
|
||||||
if not version:
|
if not version:
|
||||||
return api_error('Version is required', 400)
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Version is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = dismiss_update(version)
|
result = dismiss_update(version)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error dismissing update: {e}")
|
logger.error(f"Error dismissing update: {e}")
|
||||||
return api_error(str(e), 500)
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@updater_bp.route('/restart', methods=['POST'])
|
@updater_bp.route('/restart', methods=['POST'])
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
"""VDL2 aircraft datalink routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import pty
|
|
||||||
import queue
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
from utils.acars_translator import translate_message
|
|
||||||
from utils.constants import (
|
|
||||||
PROCESS_START_WAIT,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
)
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.flight_correlator import get_flight_correlator
|
|
||||||
from utils.logging import sensor_logger as logger
|
|
||||||
from utils.process import register_process, unregister_process
|
|
||||||
from utils.responses import api_error
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sse import sse_stream_fanout
|
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
|
||||||
|
|
||||||
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
|
||||||
|
|
||||||
# Default VDL2 frequencies (MHz) - common worldwide
|
|
||||||
DEFAULT_VDL2_FREQUENCIES = [
|
|
||||||
'136975000', # Primary worldwide
|
|
||||||
'136725000', # Europe
|
|
||||||
'136775000', # Europe
|
|
||||||
'136800000', # Multi-region
|
|
||||||
'136875000', # Multi-region
|
|
||||||
]
|
|
||||||
|
|
||||||
# Message counter for statistics
|
|
||||||
vdl2_message_count = 0
|
|
||||||
vdl2_last_message_time = None
|
|
||||||
|
|
||||||
# Track which device is being used
|
|
||||||
vdl2_active_device: int | None = None
|
|
||||||
vdl2_active_sdr_type: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def find_dumpvdl2():
|
|
||||||
"""Find dumpvdl2 binary."""
|
|
||||||
return shutil.which('dumpvdl2')
|
|
||||||
|
|
||||||
|
|
||||||
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
|
||||||
"""Stream dumpvdl2 JSON output to queue."""
|
|
||||||
global vdl2_message_count, vdl2_last_message_time
|
|
||||||
|
|
||||||
try:
|
|
||||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
|
||||||
|
|
||||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
|
||||||
sentinel = '' if is_text_mode else b''
|
|
||||||
for line in iter(process.stdout.readline, sentinel):
|
|
||||||
if is_text_mode:
|
|
||||||
line = line.strip()
|
|
||||||
else:
|
|
||||||
line = line.decode('utf-8', errors='replace').strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(line)
|
|
||||||
|
|
||||||
# Add our metadata
|
|
||||||
data['type'] = 'vdl2'
|
|
||||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
|
||||||
|
|
||||||
# Enrich with translated ACARS label at top level (consistent with ACARS route)
|
|
||||||
try:
|
|
||||||
vdl2_inner = data.get('vdl2', data)
|
|
||||||
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
|
|
||||||
if acars_payload and acars_payload.get('label'):
|
|
||||||
translation = translate_message({
|
|
||||||
'label': acars_payload.get('label'),
|
|
||||||
'text': acars_payload.get('msg_text', ''),
|
|
||||||
})
|
|
||||||
data['label_description'] = translation['label_description']
|
|
||||||
data['message_type'] = translation['message_type']
|
|
||||||
data['parsed'] = translation['parsed']
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Update stats
|
|
||||||
vdl2_message_count += 1
|
|
||||||
vdl2_last_message_time = time.time()
|
|
||||||
|
|
||||||
app_module.vdl2_queue.put(data)
|
|
||||||
|
|
||||||
# Feed flight correlator
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
get_flight_correlator().add_vdl2_message(data)
|
|
||||||
|
|
||||||
# Log if enabled
|
|
||||||
if app_module.logging_enabled:
|
|
||||||
try:
|
|
||||||
with open(app_module.log_file_path, 'a') as f:
|
|
||||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Not JSON - could be status message
|
|
||||||
if line:
|
|
||||||
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"VDL2 stream error: {e}")
|
|
||||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
|
||||||
finally:
|
|
||||||
global vdl2_active_device, vdl2_active_sdr_type
|
|
||||||
# Ensure process is terminated
|
|
||||||
try:
|
|
||||||
process.terminate()
|
|
||||||
process.wait(timeout=2)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
process.kill()
|
|
||||||
unregister_process(process)
|
|
||||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
|
||||||
with app_module.vdl2_lock:
|
|
||||||
app_module.vdl2_process = None
|
|
||||||
# Release SDR device
|
|
||||||
if vdl2_active_device is not None:
|
|
||||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
||||||
vdl2_active_device = None
|
|
||||||
vdl2_active_sdr_type = None
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/tools')
|
|
||||||
def check_vdl2_tools() -> Response:
|
|
||||||
"""Check for VDL2 decoding tools."""
|
|
||||||
has_dumpvdl2 = find_dumpvdl2() is not None
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'dumpvdl2': has_dumpvdl2,
|
|
||||||
'ready': has_dumpvdl2
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/status')
|
|
||||||
def vdl2_status() -> Response:
|
|
||||||
"""Get VDL2 decoder status."""
|
|
||||||
running = False
|
|
||||||
if app_module.vdl2_process:
|
|
||||||
running = app_module.vdl2_process.poll() is None
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'running': running,
|
|
||||||
'message_count': vdl2_message_count,
|
|
||||||
'last_message_time': vdl2_last_message_time,
|
|
||||||
'queue_size': app_module.vdl2_queue.qsize()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/start', methods=['POST'])
|
|
||||||
def start_vdl2() -> Response:
|
|
||||||
"""Start VDL2 decoder."""
|
|
||||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
|
|
||||||
|
|
||||||
with app_module.vdl2_lock:
|
|
||||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
|
||||||
return api_error('VDL2 decoder already running', 409)
|
|
||||||
|
|
||||||
# Check for dumpvdl2
|
|
||||||
dumpvdl2_path = find_dumpvdl2()
|
|
||||||
if not dumpvdl2_path:
|
|
||||||
return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400)
|
|
||||||
|
|
||||||
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 api_error(str(e), 400)
|
|
||||||
|
|
||||||
# Resolve SDR type for device selection
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
# Check if device is available
|
|
||||||
device_int = int(device)
|
|
||||||
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
|
||||||
if error:
|
|
||||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
|
||||||
|
|
||||||
vdl2_active_device = device_int
|
|
||||||
vdl2_active_sdr_type = sdr_type_str
|
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
|
||||||
# dumpvdl2 expects frequencies in Hz (integers)
|
|
||||||
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
|
|
||||||
if isinstance(frequencies, str):
|
|
||||||
frequencies = [f.strip() for f in frequencies.split(',')]
|
|
||||||
|
|
||||||
# Clear queue
|
|
||||||
while not app_module.vdl2_queue.empty():
|
|
||||||
try:
|
|
||||||
app_module.vdl2_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Reset stats
|
|
||||||
vdl2_message_count = 0
|
|
||||||
vdl2_last_message_time = None
|
|
||||||
|
|
||||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
|
||||||
|
|
||||||
# Build dumpvdl2 command
|
|
||||||
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
|
||||||
cmd = [dumpvdl2_path]
|
|
||||||
cmd.extend(['--output', 'decoded:json:file:path=-'])
|
|
||||||
|
|
||||||
if is_soapy:
|
|
||||||
# SoapySDR device
|
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
device_str = builder._build_device_string(sdr_device)
|
|
||||||
cmd.extend(['--soapysdr', device_str])
|
|
||||||
else:
|
|
||||||
cmd.extend(['--rtlsdr', str(device)])
|
|
||||||
|
|
||||||
# Add gain
|
|
||||||
if gain and str(gain) != '0':
|
|
||||||
cmd.extend(['--gain', str(gain)])
|
|
||||||
|
|
||||||
# Add PPM correction if specified
|
|
||||||
if ppm and str(ppm) != '0':
|
|
||||||
cmd.extend(['--correction', str(ppm)])
|
|
||||||
|
|
||||||
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
|
|
||||||
cmd.extend(frequencies)
|
|
||||||
|
|
||||||
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_text_mode = False
|
|
||||||
|
|
||||||
# On macOS, use pty to avoid stdout buffering issues
|
|
||||||
if platform.system() == 'Darwin':
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
|
||||||
process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=slave_fd,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
start_new_session=True
|
|
||||||
)
|
|
||||||
os.close(slave_fd)
|
|
||||||
# Wrap master_fd as a text file for line-buffered reading
|
|
||||||
process.stdout = open(master_fd, buffering=1)
|
|
||||||
is_text_mode = True
|
|
||||||
else:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
start_new_session=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait briefly to check if process started
|
|
||||||
time.sleep(PROCESS_START_WAIT)
|
|
||||||
|
|
||||||
if process.poll() is not None:
|
|
||||||
# Process died - release device
|
|
||||||
if vdl2_active_device is not None:
|
|
||||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
||||||
vdl2_active_device = None
|
|
||||||
vdl2_active_sdr_type = None
|
|
||||||
stderr = ''
|
|
||||||
if process.stderr:
|
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
|
||||||
if stderr:
|
|
||||||
logger.error(f"dumpvdl2 stderr:\n{stderr}")
|
|
||||||
error_msg = 'dumpvdl2 failed to start'
|
|
||||||
if stderr:
|
|
||||||
error_msg += f': {stderr[:500]}'
|
|
||||||
logger.error(error_msg)
|
|
||||||
return api_error(error_msg, 500)
|
|
||||||
|
|
||||||
app_module.vdl2_process = process
|
|
||||||
register_process(process)
|
|
||||||
|
|
||||||
# Start output streaming thread
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=stream_vdl2_output,
|
|
||||||
args=(process, is_text_mode),
|
|
||||||
daemon=True
|
|
||||||
)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'frequencies': frequencies,
|
|
||||||
'device': device,
|
|
||||||
'gain': gain
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Release device on failure
|
|
||||||
if vdl2_active_device is not None:
|
|
||||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
||||||
vdl2_active_device = None
|
|
||||||
vdl2_active_sdr_type = None
|
|
||||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
|
||||||
return api_error(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/stop', methods=['POST'])
|
|
||||||
def stop_vdl2() -> Response:
|
|
||||||
"""Stop VDL2 decoder."""
|
|
||||||
global vdl2_active_device, vdl2_active_sdr_type
|
|
||||||
|
|
||||||
with app_module.vdl2_lock:
|
|
||||||
if not app_module.vdl2_process:
|
|
||||||
return api_error('VDL2 decoder not running', 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
app_module.vdl2_process.terminate()
|
|
||||||
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
app_module.vdl2_process.kill()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error stopping VDL2: {e}")
|
|
||||||
|
|
||||||
app_module.vdl2_process = None
|
|
||||||
|
|
||||||
# Release device from registry
|
|
||||||
if vdl2_active_device is not None:
|
|
||||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
||||||
vdl2_active_device = None
|
|
||||||
vdl2_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/stream')
|
|
||||||
def stream_vdl2() -> Response:
|
|
||||||
"""SSE stream for VDL2 messages."""
|
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
|
||||||
process_event('vdl2', msg, msg.get('type'))
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=app_module.vdl2_queue,
|
|
||||||
channel_key='vdl2',
|
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
|
||||||
on_message=_on_msg,
|
|
||||||
),
|
|
||||||
mimetype='text/event-stream',
|
|
||||||
)
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/messages')
|
|
||||||
def get_vdl2_messages() -> Response:
|
|
||||||
"""Get recent VDL2 messages from correlator (for history reload)."""
|
|
||||||
limit = request.args.get('limit', 50, type=int)
|
|
||||||
limit = max(1, min(limit, 200))
|
|
||||||
msgs = get_flight_correlator().get_recent_messages('vdl2', limit)
|
|
||||||
return jsonify(msgs)
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/clear', methods=['POST'])
|
|
||||||
def clear_vdl2_messages() -> Response:
|
|
||||||
"""Clear stored VDL2 messages and reset counter."""
|
|
||||||
global vdl2_message_count, vdl2_last_message_time
|
|
||||||
get_flight_correlator().clear_vdl2()
|
|
||||||
vdl2_message_count = 0
|
|
||||||
vdl2_last_message_time = None
|
|
||||||
return jsonify({'status': 'cleared'})
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/frequencies')
|
|
||||||
def get_frequencies() -> Response:
|
|
||||||
"""Get default VDL2 frequencies."""
|
|
||||||
return jsonify({
|
|
||||||
'default': DEFAULT_VDL2_FREQUENCIES,
|
|
||||||
'regions': {
|
|
||||||
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
|
||||||
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
|
||||||
'asia_pacific': ['136975000', '136900000'],
|
|
||||||
}
|
|
||||||
})
|
|
||||||