mirror of
https://github.com/smittix/intercept.git
synced 2026-06-15 00:53:37 -07:00
Compare commits
345 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab033b35d3 | |||
| e383575c80 | |||
| fd12d11fab | |||
| 0fbb446209 | |||
| 4ea64bd7ef | |||
| 7d9a220230 | |||
| 0afa15bb16 | |||
| d66ab01d34 | |||
| 91989a0216 | |||
| 7b4ad20805 | |||
| a1b0616ee6 | |||
| a146a21285 | |||
| 87a5715f30 | |||
| 52a28167c9 | |||
| 1403d49049 | |||
| 9090b415cc | |||
| 3f1606c38f | |||
| 18db66bce3 | |||
| 10077eee60 | |||
| 14568f8cc7 | |||
| 93fb694e25 | |||
| cde24642ac | |||
| b4757b1589 | |||
| f771100a4c | |||
| 0c3ccac21c | |||
| 4c282bb055 | |||
| 4741124d94 | |||
| 9afd99bf7c | |||
| fef54e5276 | |||
| f62c9871c4 | |||
| 0e03b84260 | |||
| f73f3466fd | |||
| 8d91c200a5 | |||
| 9bf75a069e | |||
| ec62cd9083 | |||
| 302b150c36 | |||
| cf022ed1c0 | |||
| d3326409bf | |||
| 3de5e68e68 | |||
| 325dafacbc | |||
| 2f5f429e83 | |||
| fb4482fac7 | |||
| 32f04d4ed8 | |||
| 38644bced6 | |||
| f3d475d53a | |||
| 195c224189 | |||
| f07ec23da9 | |||
| 4b64862eb4 | |||
| eea44f9a6b | |||
| de3f972aa2 | |||
| 6a334c61df | |||
| 63994ec1d4 | |||
| 6011d6fb41 | |||
| 845629ea46 | |||
| 7311dd10ab | |||
| e2e92b6b38 | |||
| 5534493bd1 | |||
| 86fa6326e9 | |||
| be70d2e43b | |||
| e89a0ef486 | |||
| bcf447fe4e | |||
| 90b455aa6c | |||
| f8e5d61fa9 | |||
| bd67195238 | |||
| d78ab5cc2c | |||
| d087780d9f | |||
| 8379f42ec3 | |||
| ff9961b846 | |||
| 5e99d19165 | |||
| 0df412c014 | |||
| e756a00cc9 | |||
| c35131462e | |||
| bad637591a | |||
| 910b69594d | |||
| a154601e86 | |||
| bdeb32e723 | |||
| 2de592f798 | |||
| b5c3d71247 | |||
| c1339b6c65 | |||
| 153aacba03 | |||
| bcbadac995 | |||
| a6e62f4674 | |||
| 77255e015d | |||
| 6cbe94cf20 | |||
| cdf10e1d6a | |||
| a22244a041 | |||
| 9371fccd62 | |||
| b4b6fdc0fc | |||
| 9e3fcb8edd | |||
| 2c7909e502 | |||
| 003c4d62cf | |||
| 10e4804e0a | |||
| 05edfb93dc | |||
| e5006a9896 | |||
| 7d1fcfe895 | |||
| c6e8602184 | |||
| 29873fb3c0 | |||
| 4f096c6c01 | |||
| 9e911e845f | |||
| 377519fd95 | |||
| fb064a22fb | |||
| 7af6d45ca1 | |||
| 54987e4c8d | |||
| 7683a925df | |||
| 824514d922 | |||
| 79a0dae04b | |||
| e176438934 | |||
| 3254d82d11 | |||
| 24d50c921e | |||
| db2f3fc8e5 | |||
| 952736c127 | |||
| 997dac3b9f | |||
| 3f6fa5ba28 | |||
| 5b06c57565 | |||
| 5aa68a49c6 | |||
| 0d13638d70 | |||
| f9dc54cc3b | |||
| f679433ac0 | |||
| 4b31474080 | |||
| f72b43c6bf | |||
| 0a90010c1f | |||
| 81a8f24e27 | |||
| 4712616339 | |||
| 1cfeb193c7 | |||
| 69b402f872 | |||
| deb7e2d15d | |||
| 645b3b8632 | |||
| ee81eb44cd | |||
| fd3552e725 | |||
| 818d9c9f90 | |||
| dc0775f7df | |||
| c0fb22124b | |||
| 97b10b3ac9 | |||
| be522d4dfe | |||
| 33a360b483 | |||
| 2e1b9b27be | |||
| d6fe1123b4 | |||
| 5fcfa2f72f | |||
| 24d1777e63 | |||
| 794dd693cf | |||
| 0cadf07985 | |||
| bb263ce1b0 | |||
| 23d592af1d | |||
| ababa63856 | |||
| fdffb8e88e | |||
| 98642e43c7 | |||
| 8cb7edf41e | |||
| 64f0e687a0 | |||
| 6a54bc8cf3 | |||
| b32d30b789 | |||
| d3b737c19b | |||
| 146bca4b37 | |||
| e3cf9daaed | |||
| 81e5f5479f | |||
| a5eefc712a | |||
| a50d200af4 | |||
| 99db7f1faf | |||
| 4560ec1800 | |||
| d92146d678 | |||
| 70e4bc557b | |||
| c1dd615e11 | |||
| 63cc1647fb | |||
| d9228fb05a | |||
| 806bc1397a | |||
| 7560691fbb | |||
| 8eb4ff41e2 | |||
| 286ab53d26 | |||
| 5d90c308a9 | |||
| 9622a00ea1 | |||
| 7c9ef9b895 | |||
| bfae73cabf | |||
| c0c066904c | |||
| 2eea28da05 | |||
| df84c42b8b | |||
| 860db12200 | |||
| 0bf8341b6c | |||
| 2ec458aa14 | |||
| 5f583e5718 | |||
| deea80e32c | |||
| 37f0197f9a | |||
| dc7c05b03f | |||
| 8a46293e5c | |||
| 935b7a4d9d | |||
| a50f77629c | |||
| ecdc060d81 | |||
| ee9356c358 | |||
| 7fdf162f1e | |||
| 56514a839f | |||
| dbf76a4e84 | |||
| 3f7430d114 | |||
| f3158cbb69 | |||
| 2202e3ed98 | |||
| 844e57e239 | |||
| 5b6df923fc | |||
| 9724ec57f9 | |||
| 2d92243341 | |||
| 6ec15461af | |||
| 2c76039f2c | |||
| c4bde6c707 | |||
| 6384e39576 | |||
| 5edfe1797c | |||
| 4bf452d462 | |||
| f6b0edaf5a | |||
| 18efed891a | |||
| 60a3ae225f | |||
| afd3d34f43 | |||
| 0344862a0c | |||
| 43e6d4a1b8 | |||
| 53c65febed | |||
| cec8bccb03 | |||
| 6c20b3d23f | |||
| 53f54af871 | |||
| caa4357870 | |||
| 3e608c62a0 | |||
| 0afa25e57c | |||
| b3af44652f | |||
| 67321adade | |||
| 6894e626a9 | |||
| 9745215038 | |||
| b72a2f1092 | |||
| 2da8dca167 | |||
| 085a6177f9 | |||
| 01abcac8f2 | |||
| 2a5f537381 | |||
| 07b5b72878 | |||
| 1a1a398962 | |||
| b7d90e8e5e | |||
| 55c38522a4 | |||
| d9b528f3d3 | |||
| 9cd7f1c0c8 | |||
| a350c82893 | |||
| 6a690abf82 | |||
| e19a990b64 | |||
| 975a95e1b0 | |||
| 2af238aed5 | |||
| e81a409234 | |||
| 1c76671ed7 | |||
| 9ece4d658d | |||
| 739b0b136e | |||
| 199ff4b47c | |||
| 65e5552c7d | |||
| a5452fa1b1 | |||
| 889c08691f | |||
| 0a4a0689a0 | |||
| 0daee74cf0 | |||
| 2e6bb8882f | |||
| 365333d425 | |||
| 367048e853 | |||
| 406ca28304 | |||
| f889c53d92 | |||
| b0af1d16d2 | |||
| 4e67b77714 | |||
| b1993847b5 | |||
| cde79f4619 | |||
| cc271819ad | |||
| 8cd64ce3ca | |||
| 9705e58691 | |||
| 3acdab816a | |||
| c31ed14041 | |||
| 7241dbed35 | |||
| 94b358f686 | |||
| 8e19f7e688 | |||
| 7ea06caaa2 | |||
| 1c681b6777 | |||
| 5f480caa3f | |||
| 5d4b61b4c3 | |||
| ab064b4c91 | |||
| 26ecd3dd93 | |||
| a8e2b9d98d | |||
| 4b225db9da | |||
| aba4ccd040 | |||
| f8a6d0ae70 | |||
| 00681840c8 | |||
| 00be3e940a | |||
| c2405bfe14 | |||
| 01409cfdea | |||
| 130f58d9cc | |||
| fb2a12773a | |||
| 167f10c7f7 | |||
| 15d5cb2272 | |||
| e386016349 | |||
| aec925753e | |||
| c3bf30b49c | |||
| c0221ba53d | |||
| af5b17e841 | |||
| b628a5f751 | |||
| d28d8cb9ef | |||
| 9ec316fbe2 | |||
| a407c7708d | |||
| 1466fc2d30 | |||
| 963bcdf9fa | |||
| cfe03317c9 | |||
| 37ba12daaa | |||
| 5c47e9f10a | |||
| 694786d4e0 | |||
| 06a00ca6b5 | |||
| bbc25ddaa0 | |||
| 02a94281c3 | |||
| cbe5faab3b | |||
| cacfbf5713 | |||
| 2faed68af4 | |||
| bec0881018 | |||
| da2a700bcc | |||
| cd3ed9a03b | |||
| f7fad076c2 | |||
| a397271553 | |||
| 83a54ccb20 | |||
| 2e9bab75b1 | |||
| 0dc40bbea3 | |||
| 17f6947648 | |||
| 481651c88d | |||
| ad4903d4ac | |||
| 3a962ca207 | |||
| f29ae3d5a8 | |||
| 37d24a539d | |||
| 622f23c091 | |||
| b70db887b1 | |||
| 7f13af3fcd | |||
| 9afff0f4b2 | |||
| 5a7a6ce522 | |||
| 36b6539044 | |||
| 6c6cd8a280 | |||
| 4df112e712 | |||
| 3d8b8bbfdc | |||
| 076339024f | |||
| e82f0f36d2 | |||
| f4ade209f9 | |||
| b0652595fa | |||
| 332172881e | |||
| e05ac97749 | |||
| 615a83c23f | |||
| d017375f64 | |||
| 0b5235f619 | |||
| 16239c1d31 | |||
| cae7a0586f | |||
| 23f28a8102 | |||
| 34ecec3800 | |||
| d40bd37406 | |||
| 4ed41434e2 | |||
| 6a0b54fa0e | |||
| b83ecfcc19 | |||
| 671bf38083 | |||
| 0f5a414a09 | |||
| 831426948f | |||
| df2c0a0d25 |
@@ -0,0 +1,42 @@
|
||||
## 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.
|
||||
+32
-2
@@ -1,2 +1,32 @@
|
||||
# Uncomment and set to use external storage for ADS-B history
|
||||
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||
# =============================================================================
|
||||
# INTERCEPT CONTROLLER (.env)
|
||||
# =============================================================================
|
||||
# Copy to .env and edit for your setup
|
||||
|
||||
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
|
||||
TZ=UTC
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Force LF line endings for files that must run on Linux (Docker)
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
+6
-4
@@ -18,10 +18,6 @@ pager_messages.log
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -58,6 +54,9 @@ intercept_agent_*.cfg
|
||||
# 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/
|
||||
|
||||
@@ -65,3 +64,6 @@ data/subghz/captures/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local utility scripts
|
||||
reset-sdr.*
|
||||
|
||||
+136
@@ -2,6 +2,142 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -25,15 +25,25 @@ docker compose --profile basic up -d --build
|
||||
|
||||
### Local Setup (Alternative)
|
||||
```bash
|
||||
# Initial setup (installs dependencies and configures SDR tools)
|
||||
# First-time setup (interactive wizard with install profiles)
|
||||
./setup.sh
|
||||
|
||||
# Run the application (requires sudo for SDR/network access)
|
||||
# Or headless full install
|
||||
./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
|
||||
|
||||
# Or activate venv first
|
||||
source venv/bin/activate
|
||||
sudo -E python intercept.py
|
||||
# Other setup utilities
|
||||
./setup.sh --health-check # Verify installation
|
||||
./setup.sh --postgres-setup # Set up ADS-B history database
|
||||
./setup.sh --menu # Force interactive menu
|
||||
```
|
||||
|
||||
### Testing
|
||||
@@ -69,8 +79,10 @@ mypy .
|
||||
## Architecture
|
||||
|
||||
### Entry Points
|
||||
- `intercept.py` - Main entry point script
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
||||
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
|
||||
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
|
||||
- `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/)
|
||||
Each signal type has its own Flask blueprint:
|
||||
@@ -121,7 +133,7 @@ Each signal type has its own Flask blueprint:
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
|
||||
|
||||
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||
|
||||
@@ -152,7 +164,7 @@ Each signal type has its own Flask blueprint:
|
||||
- **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.)
|
||||
- `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
|
||||
|
||||
+29
-27
@@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
soapysdr-module-airspy \
|
||||
airspy \
|
||||
limesuite \
|
||||
hackrf \
|
||||
# Utilities
|
||||
curl \
|
||||
procps \
|
||||
@@ -92,9 +91,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
libfftw3-bin \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
libglib2.0-dev \
|
||||
libxml2-dev \
|
||||
# Build dump1090
|
||||
@@ -191,6 +189,28 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fi \
|
||||
&& cd /tmp \
|
||||
&& 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
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
||||
&& cd hackrf/host \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/hackrf \
|
||||
# Install radiosonde_auto_rx (weather balloon decoder)
|
||||
&& 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 /opt/radiosonde_auto_rx/auto_rx \
|
||||
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \
|
||||
&& chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/radiosonde_auto_rx \
|
||||
# Build rtlamr (utility meter decoder - requires Go)
|
||||
&& cd /tmp \
|
||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||
@@ -199,27 +219,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& go install github.com/bemasher/rtlamr@latest \
|
||||
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||
&& rm -rf /usr/local/go /tmp/gopath \
|
||||
# Build mbelib (required by DSD)
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/lwvmobile/mbelib.git \
|
||||
&& 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
|
||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||
&& apt-get remove -y \
|
||||
@@ -247,7 +246,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -258,11 +256,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
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
|
||||
RUN mkdir -p /app/data /app/data/weather_sat
|
||||
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
EXPOSE 5443
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||
@@ -275,4 +277,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -sf http://localhost:5050/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "intercept.py"]
|
||||
CMD ["/bin/bash", "start.sh"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<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/license-MIT-green.svg" alt="MIT License">
|
||||
<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/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||
</p>
|
||||
|
||||
@@ -40,30 +40,101 @@ Support the developer of this open-source project
|
||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
||||
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||
- **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
|
||||
- **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
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
|
||||
---
|
||||
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
## CW / Morse Decoder Notes
|
||||
|
||||
```
|
||||
Live backend:
|
||||
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
||||
|
||||
Recommended baseline settings:
|
||||
- **Tone**: `700 Hz`
|
||||
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
||||
- **Threshold Mode**: `Auto`
|
||||
- **WPM Mode**: `Auto`
|
||||
|
||||
Auto Tone Track behavior:
|
||||
- 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
|
||||
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py
|
||||
./setup.sh # Interactive menu (first run launches setup wizard)
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -115,16 +186,40 @@ INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
||||
docker compose --profile basic up -d
|
||||
```
|
||||
|
||||
### ADS-B History (Optional)
|
||||
### Environment Configuration
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
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)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to PostgreSQL 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
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Set the following environment variables (for example in a `.env` file):
|
||||
Set the following environment variables (in `.env`):
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
@@ -135,30 +230,6 @@ INTERCEPT_ADSB_DB_USER=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`):
|
||||
|
||||
```bash
|
||||
@@ -167,11 +238,22 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
|
||||
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
|
||||
|
||||
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 change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
|
||||
---
|
||||
|
||||
@@ -244,7 +326,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
||||
[rtlamr](https://github.com/bemasher/rtlamr) |
|
||||
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-02-01_ba81b697",
|
||||
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||
"version": "2026-02-22_17194a71",
|
||||
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import subprocess
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
@@ -42,8 +42,12 @@ from utils.constants import (
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
import logging
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
try:
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
_has_limiter = True
|
||||
except ImportError:
|
||||
_has_limiter = False
|
||||
# Track application start time for uptime calculation
|
||||
import time as _time
|
||||
_app_start_time = _time.time()
|
||||
@@ -54,11 +58,24 @@ app = Flask(__name__)
|
||||
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||
|
||||
# Set up rate limiting
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address, # Identifies the user by their IP
|
||||
app=app,
|
||||
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
||||
)
|
||||
if _has_limiter:
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
app=app,
|
||||
storage_uri="memory://",
|
||||
)
|
||||
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()
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
@@ -100,11 +117,24 @@ def add_security_headers(response):
|
||||
def inject_offline_settings():
|
||||
"""Inject offline settings into all templates."""
|
||||
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 {
|
||||
'offline_settings': {
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'assets_source': assets_source,
|
||||
'fonts_source': fonts_source,
|
||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
@@ -177,12 +207,6 @@ dsc_rtl_process = None
|
||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
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_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
@@ -191,6 +215,26 @@ tscm_lock = threading.Lock()
|
||||
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_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
@@ -245,12 +289,12 @@ cleanup_manager.register(deauth_alerts)
|
||||
# SDR DEVICE REGISTRY
|
||||
# ============================================
|
||||
# Tracks which mode is using which SDR device to prevent conflicts
|
||||
# Key: device_index (int), Value: mode_name (str)
|
||||
sdr_device_registry: dict[int, str] = {}
|
||||
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
|
||||
sdr_device_registry: dict[str, str] = {}
|
||||
sdr_device_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
|
||||
"""Claim an SDR device for a mode.
|
||||
|
||||
Checks the in-app registry first, then probes the USB device to
|
||||
@@ -260,43 +304,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
Args:
|
||||
device_index: The SDR device index to claim
|
||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||
|
||||
Returns:
|
||||
Error message if device is in use, None if successfully claimed
|
||||
"""
|
||||
key = f"{sdr_type}:{device_index}"
|
||||
with sdr_device_registry_lock:
|
||||
if device_index in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[device_index]
|
||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||
if key in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[key]
|
||||
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.'
|
||||
|
||||
# Probe the USB device to catch external processes holding the handle
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
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[device_index] = mode_name
|
||||
sdr_device_registry[key] = mode_name
|
||||
return None
|
||||
|
||||
|
||||
def release_sdr_device(device_index: int) -> None:
|
||||
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
|
||||
"""Release an SDR device from the registry.
|
||||
|
||||
Args:
|
||||
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:
|
||||
sdr_device_registry.pop(device_index, None)
|
||||
sdr_device_registry.pop(key, None)
|
||||
|
||||
|
||||
def get_sdr_device_status() -> dict[int, str]:
|
||||
def get_sdr_device_status() -> dict[str, str]:
|
||||
"""Get current SDR device allocations.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device indices to mode names
|
||||
Dictionary mapping 'sdr_type:device_index' keys to mode names
|
||||
"""
|
||||
with sdr_device_registry_lock:
|
||||
return dict(sdr_device_registry)
|
||||
@@ -389,6 +438,18 @@ def favicon() -> Response:
|
||||
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')
|
||||
def get_devices() -> Response:
|
||||
"""Get all detected SDR devices with hardware type info."""
|
||||
@@ -405,8 +466,9 @@ def get_devices_status() -> Response:
|
||||
result = []
|
||||
for device in devices:
|
||||
d = device.to_dict()
|
||||
d['in_use'] = device.index in registry
|
||||
d['used_by'] = registry.get(device.index)
|
||||
key = f"{device.sdr_type.value}:{device.index}"
|
||||
d['in_use'] = key in registry
|
||||
d['used_by'] = registry.get(key)
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result)
|
||||
@@ -661,20 +723,89 @@ def _get_subghz_active() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_dmr_active() -> bool:
|
||||
"""Check if Digital Voice decoder has an active process."""
|
||||
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:
|
||||
from routes import dmr as dmr_module
|
||||
proc = dmr_module.dmr_dsd_process
|
||||
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||
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')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import time
|
||||
bt_active, bt_device_count = _get_bluetooth_health()
|
||||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'version': VERSION,
|
||||
@@ -687,18 +818,28 @@ def health_check() -> Response:
|
||||
'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),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'wifi': wifi_active,
|
||||
'bluetooth': bt_active,
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'dmr': _get_dmr_active(),
|
||||
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_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': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'vessel_count': len(ais_vessels),
|
||||
'wifi_networks_count': len(wifi_networks),
|
||||
'wifi_clients_count': len(wifi_clients),
|
||||
'bt_devices_count': len(bt_devices),
|
||||
'wifi_networks_count': wifi_network_count,
|
||||
'wifi_clients_count': wifi_client_count,
|
||||
'bt_devices_count': bt_device_count,
|
||||
'dsc_messages_count': len(dsc_messages),
|
||||
}
|
||||
})
|
||||
@@ -708,13 +849,13 @@ def health_check() -> Response:
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global vdl2_process
|
||||
global vdl2_process, morse_process, radiosonde_process, ook_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
global dmr_process, dmr_rtl_process
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
# Import modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
from routes import radiosonde as radiosonde_module
|
||||
from utils.bluetooth import reset_bluetooth_scanner
|
||||
|
||||
killed = []
|
||||
@@ -722,9 +863,10 @@ def kill_all() -> Response:
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||
'hcitool', 'bluetoothctl', 'satdump',
|
||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||
'hackrf_transfer', 'hackrf_sweep'
|
||||
'hackrf_transfer', 'hackrf_sweep',
|
||||
'auto_rx'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -754,6 +896,11 @@ def kill_all() -> Response:
|
||||
ais_process = None
|
||||
ais_module.ais_running = False
|
||||
|
||||
# Reset Radiosonde state
|
||||
with radiosonde_lock:
|
||||
radiosonde_process = None
|
||||
radiosonde_module.radiosonde_running = False
|
||||
|
||||
# Reset ACARS state
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
@@ -762,6 +909,21 @@ def kill_all() -> Response:
|
||||
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
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
@@ -772,11 +934,6 @@ def kill_all() -> Response:
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
|
||||
# Reset DMR state
|
||||
with dmr_lock:
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
|
||||
# Reset Bluetooth state (legacy)
|
||||
with bt_lock:
|
||||
if bt_process:
|
||||
@@ -811,6 +968,139 @@ def kill_all() -> Response:
|
||||
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_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
|
||||
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:
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
@@ -837,6 +1127,12 @@ def main() -> None:
|
||||
default=config.DEBUG,
|
||||
help='Enable debug mode'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--https',
|
||||
action='store_true',
|
||||
default=config.HTTPS,
|
||||
help='Enable HTTPS with self-signed certificate'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--check-deps',
|
||||
action='store_true',
|
||||
@@ -886,83 +1182,21 @@ def main() -> None:
|
||||
print("Running as root - full capabilities enabled")
|
||||
print()
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
cleanup_stale_dump1090()
|
||||
# Ensure app is initialized (no-op if already done by module-level call)
|
||||
_init_app()
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
# Configure SSL if HTTPS is enabled
|
||||
ssl_context = None
|
||||
if args.https:
|
||||
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
|
||||
if config.SSL_CERT and config.SSL_KEY:
|
||||
ssl_context = (config.SSL_CERT, config.SSL_KEY)
|
||||
print(f"Using provided SSL certificate: {config.SSL_CERT}")
|
||||
else:
|
||||
ssl_context = _ensure_self_signed_cert(cert_dir)
|
||||
|
||||
# Register database cleanup functions
|
||||
from utils.database import (
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||
|
||||
# Start automatic cleanup of stale data entries
|
||||
cleanup_manager.start()
|
||||
|
||||
# Register blueprints
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
import os
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
# Update TLE data in background thread (non-blocking)
|
||||
def update_tle_background():
|
||||
try:
|
||||
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:
|
||||
print("TLE update: No satellites updated (may be offline)")
|
||||
except Exception as e:
|
||||
print(f"TLE update failed (will use cached data): {e}")
|
||||
|
||||
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
|
||||
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}")
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
print("WebSocket waterfall streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket waterfall disabled: {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
protocol = 'https' if ssl_context else 'http'
|
||||
print(f"Open {protocol}://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
print()
|
||||
@@ -974,4 +1208,5 @@ def main() -> None:
|
||||
debug=args.debug,
|
||||
threaded=True,
|
||||
load_dotenv=False,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
+139
-139
@@ -1,139 +1,139 @@
|
||||
#!/bin/bash
|
||||
# INTERCEPT - Multi-architecture Docker image builder
|
||||
#
|
||||
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
||||
# Run this on your x64 machine to cross-compile the arm64 image
|
||||
# instead of building natively on the RPi5.
|
||||
#
|
||||
# Prerequisites (one-time setup):
|
||||
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
# docker buildx create --name intercept-builder --use --bootstrap
|
||||
#
|
||||
# Usage:
|
||||
# ./build-multiarch.sh # Build both platforms, load locally
|
||||
# ./build-multiarch.sh --push # Build and push to registry
|
||||
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
||||
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Container registry (default: docker.io/library)
|
||||
# IMAGE_NAME - Image name (default: intercept)
|
||||
# IMAGE_TAG - Image tag (default: latest)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
BUILDER_NAME="intercept-builder"
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
# Parse arguments
|
||||
PUSH=false
|
||||
LOAD=false
|
||||
ARM64_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--push) PUSH=true ;;
|
||||
--load) LOAD=true ;;
|
||||
--arm64-only)
|
||||
ARM64_ONLY=true
|
||||
PLATFORMS="linux/arm64"
|
||||
;;
|
||||
--amd64-only)
|
||||
PLATFORMS="linux/amd64"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --push Push to container registry"
|
||||
echo " --load Load into local Docker (single platform only)"
|
||||
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
||||
echo " --amd64-only Build amd64 only"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
||||
echo " IMAGE_NAME Image name (default: intercept)"
|
||||
echo " IMAGE_TAG Image tag (default: latest)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 --push # Build both, push"
|
||||
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
||||
echo " $0 --arm64-only --load # Build arm64, load locally"
|
||||
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Build full image reference
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo " INTERCEPT Multi-Architecture Builder"
|
||||
echo "============================================"
|
||||
echo " Image: ${FULL_IMAGE}"
|
||||
echo " Platforms: ${PLATFORMS}"
|
||||
echo " Push: ${PUSH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Check if buildx builder exists, create if not
|
||||
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||
echo "Creating buildx builder: ${BUILDER_NAME}"
|
||||
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||
|
||||
# Check for QEMU support
|
||||
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
||||
echo "WARNING: QEMU binfmt setup may have failed."
|
||||
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
||||
fi
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
|
||||
# Build command
|
||||
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
||||
|
||||
if [ "$PUSH" = true ]; then
|
||||
BUILD_CMD="${BUILD_CMD} --push"
|
||||
echo "Will push to: ${FULL_IMAGE}"
|
||||
elif [ "$LOAD" = true ]; then
|
||||
# --load only works with single platform
|
||||
if echo "$PLATFORMS" | grep -q ","; then
|
||||
echo "ERROR: --load only works with a single platform."
|
||||
echo "Use --arm64-only or --amd64-only with --load."
|
||||
exit 1
|
||||
fi
|
||||
BUILD_CMD="${BUILD_CMD} --load"
|
||||
echo "Will load into local Docker"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building..."
|
||||
echo "Command: ${BUILD_CMD} ."
|
||||
echo ""
|
||||
|
||||
$BUILD_CMD .
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Build complete!"
|
||||
if [ "$PUSH" = true ]; then
|
||||
echo " Image pushed to: ${FULL_IMAGE}"
|
||||
echo ""
|
||||
echo " Pull on RPi5:"
|
||||
echo " docker pull ${FULL_IMAGE}"
|
||||
fi
|
||||
echo "============================================"
|
||||
#!/bin/bash
|
||||
# INTERCEPT - Multi-architecture Docker image builder
|
||||
#
|
||||
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
||||
# Run this on your x64 machine to cross-compile the arm64 image
|
||||
# instead of building natively on the RPi5.
|
||||
#
|
||||
# Prerequisites (one-time setup):
|
||||
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
# docker buildx create --name intercept-builder --use --bootstrap
|
||||
#
|
||||
# Usage:
|
||||
# ./build-multiarch.sh # Build both platforms, load locally
|
||||
# ./build-multiarch.sh --push # Build and push to registry
|
||||
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
||||
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Container registry (default: docker.io/library)
|
||||
# IMAGE_NAME - Image name (default: intercept)
|
||||
# IMAGE_TAG - Image tag (default: latest)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
BUILDER_NAME="intercept-builder"
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
# Parse arguments
|
||||
PUSH=false
|
||||
LOAD=false
|
||||
ARM64_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--push) PUSH=true ;;
|
||||
--load) LOAD=true ;;
|
||||
--arm64-only)
|
||||
ARM64_ONLY=true
|
||||
PLATFORMS="linux/arm64"
|
||||
;;
|
||||
--amd64-only)
|
||||
PLATFORMS="linux/amd64"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --push Push to container registry"
|
||||
echo " --load Load into local Docker (single platform only)"
|
||||
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
||||
echo " --amd64-only Build amd64 only"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
||||
echo " IMAGE_NAME Image name (default: intercept)"
|
||||
echo " IMAGE_TAG Image tag (default: latest)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 --push # Build both, push"
|
||||
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
||||
echo " $0 --arm64-only --load # Build arm64, load locally"
|
||||
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Build full image reference
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo " INTERCEPT Multi-Architecture Builder"
|
||||
echo "============================================"
|
||||
echo " Image: ${FULL_IMAGE}"
|
||||
echo " Platforms: ${PLATFORMS}"
|
||||
echo " Push: ${PUSH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Check if buildx builder exists, create if not
|
||||
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||
echo "Creating buildx builder: ${BUILDER_NAME}"
|
||||
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||
|
||||
# Check for QEMU support
|
||||
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
||||
echo "WARNING: QEMU binfmt setup may have failed."
|
||||
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
||||
fi
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
|
||||
# Build command
|
||||
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
||||
|
||||
if [ "$PUSH" = true ]; then
|
||||
BUILD_CMD="${BUILD_CMD} --push"
|
||||
echo "Will push to: ${FULL_IMAGE}"
|
||||
elif [ "$LOAD" = true ]; then
|
||||
# --load only works with single platform
|
||||
if echo "$PLATFORMS" | grep -q ","; then
|
||||
echo "ERROR: --load only works with a single platform."
|
||||
echo "Use --arm64-only or --amd64-only with --load."
|
||||
exit 1
|
||||
fi
|
||||
BUILD_CMD="${BUILD_CMD} --load"
|
||||
echo "Will load into local Docker"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building..."
|
||||
echo "Command: ${BUILD_CMD} ."
|
||||
echo ""
|
||||
|
||||
$BUILD_CMD .
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Build complete!"
|
||||
if [ "$PUSH" = true ]; then
|
||||
echo " Image pushed to: ${FULL_IMAGE}"
|
||||
echo ""
|
||||
echo " Pull on RPi5:"
|
||||
echo " docker pull ${FULL_IMAGE}"
|
||||
fi
|
||||
echo "============================================"
|
||||
|
||||
@@ -7,10 +7,97 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.19.0"
|
||||
VERSION = "2.24.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"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",
|
||||
@@ -67,7 +154,6 @@ CHANGELOG = [
|
||||
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
||||
"SDR device lock-up fix from unreleased device registry on crash",
|
||||
]
|
||||
},
|
||||
@@ -75,8 +161,6 @@ CHANGELOG = [
|
||||
"version": "2.14.0",
|
||||
"date": "February 2026",
|
||||
"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",
|
||||
"WebSDR integration for remote HF/shortwave listening",
|
||||
"Listening Post signal scanner and audio pipeline improvements",
|
||||
@@ -222,6 +306,11 @@ PORT = _get_env_int('PORT', 5050)
|
||||
DEBUG = _get_env_bool('DEBUG', False)
|
||||
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_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||
@@ -268,12 +357,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
# Weather satellite settings
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
|
||||
WEATHER_SAT_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)
|
||||
@@ -284,6 +381,12 @@ 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
|
||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||
|
||||
@@ -26,4 +26,7 @@ TLE_SATELLITES = {
|
||||
'METEOR-M2-3': ('METEOR-M2 3',
|
||||
'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'),
|
||||
'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'),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
{
|
||||
"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,6 +1,8 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker Compose configuration for easy deployment
|
||||
#
|
||||
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
|
||||
#
|
||||
# Basic usage (build locally):
|
||||
# docker compose --profile basic up -d --build
|
||||
#
|
||||
@@ -18,6 +20,8 @@ services:
|
||||
container_name: intercept
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||
# - "5443:5443"
|
||||
# Privileged mode required for USB SDR device access
|
||||
privileged: true
|
||||
# USB device mapping for all USB devices
|
||||
@@ -29,9 +33,13 @@ services:
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
# HTTPS support (auto-generates self-signed cert)
|
||||
# - INTERCEPT_HTTPS=true
|
||||
# - INTERCEPT_PORT=5443
|
||||
# ADS-B history is disabled by default
|
||||
# To enable, use: docker compose --profile history up -d
|
||||
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
@@ -70,6 +78,8 @@ services:
|
||||
- adsb_db
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||
# - "5443:5443"
|
||||
# Privileged mode required for USB SDR device access
|
||||
privileged: true
|
||||
# USB device mapping for all USB devices
|
||||
@@ -78,9 +88,13 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
# HTTPS support (auto-generates self-signed cert)
|
||||
# - INTERCEPT_HTTPS=true
|
||||
# - INTERCEPT_PORT=5443
|
||||
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||
- INTERCEPT_ADSB_DB_PORT=5432
|
||||
@@ -108,6 +122,7 @@ services:
|
||||
profiles:
|
||||
- history
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- POSTGRES_DB=intercept_adsb
|
||||
- POSTGRES_USER=intercept
|
||||
- POSTGRES_PASSWORD=intercept
|
||||
|
||||
@@ -38,8 +38,8 @@ The controller is the main Intercept application:
|
||||
|
||||
```bash
|
||||
cd intercept
|
||||
python app.py
|
||||
# Runs on http://localhost:5050
|
||||
./setup.sh # First-time setup (choose install profiles)
|
||||
sudo ./start.sh # Production server on http://localhost:5050
|
||||
```
|
||||
|
||||
### 2. Configure an Agent
|
||||
|
||||
+88
-13
@@ -24,17 +24,6 @@ Complete feature list for all modules.
|
||||
- **Wideband spectrum analysis** with real-time visualization
|
||||
- **I/Q capture** - record raw samples for offline analysis
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||
- **Full-screen dashboard** - dedicated popout with interactive map
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
||||
- **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)
|
||||
|
||||
- **Comprehensive database** of active number stations and diplomatic networks
|
||||
@@ -111,11 +100,30 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **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
|
||||
|
||||
@@ -165,6 +173,32 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **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
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -242,6 +276,34 @@ Search and rescue Bluetooth device location with GPS-tagged signal trail mapping
|
||||
- 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.
|
||||
@@ -265,7 +327,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
||||
### Wireless Sweep Features
|
||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
|
||||
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
|
||||
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||
|
||||
@@ -364,6 +426,14 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
||||
- **Redundancy** - Multiple nodes for reliable coverage
|
||||
- **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
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
@@ -424,14 +494,19 @@ The settings modal shows availability status for each bundled asset:
|
||||
## General
|
||||
|
||||
- **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)
|
||||
- **Audio alerts** with mute toggle
|
||||
- **Message export** to CSV/JSON
|
||||
- **Signal activity meter** and waterfall display
|
||||
- **Message logging** to file with timestamps
|
||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
|
||||
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
|
||||
- **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
|
||||
- **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**
|
||||
- **Device intelligence** dashboard with tracking
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
|
||||
+172
-9
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
|
||||
|
||||
## Quick Install
|
||||
|
||||
### macOS (Homebrew)
|
||||
### Recommended: Use the Setup Script
|
||||
|
||||
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
|
||||
# Install Homebrew if needed
|
||||
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
|
||||
brew install hackrf soapyhackrf
|
||||
```
|
||||
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
```bash
|
||||
# Update package lists
|
||||
@@ -94,6 +126,126 @@ 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
|
||||
|
||||
### Check dependencies
|
||||
@@ -119,11 +271,19 @@ SoapySDRUtil --find
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Detects your OS
|
||||
- Creates a virtual environment if needed (for PEP 668 systems)
|
||||
- Installs Python dependencies
|
||||
- Checks for required tools
|
||||
The setup wizard automatically:
|
||||
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
|
||||
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
|
||||
- Creates a virtual environment with system site-packages
|
||||
- Installs Python dependencies (core + optional)
|
||||
- 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
|
||||
```bash
|
||||
@@ -139,10 +299,13 @@ pip install -r requirements.txt
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
|
||||
# Custom port
|
||||
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh -p 8080
|
||||
|
||||
# HTTPS
|
||||
sudo ./start.sh --https
|
||||
```
|
||||
|
||||
Open **http://localhost:5050** in your browser.
|
||||
|
||||
+2
-3
@@ -18,10 +18,9 @@ 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 -
|
||||
```
|
||||
|
||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||
2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
sudo ./start.sh -H 127.0.0.1
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
+17
-7
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
# Then create venv with system packages
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### "error: externally-managed-environment" (pip blocked)
|
||||
@@ -61,18 +61,21 @@ sudo apt install python3.11 python3.11-venv python3-pip
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### Alternative: Use the setup script
|
||||
|
||||
The setup script handles all installation automatically, including apt packages:
|
||||
The setup script handles all installation automatically, including apt packages and source builds:
|
||||
|
||||
```bash
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
./setup.sh # Interactive wizard (first run) or menu
|
||||
./setup.sh --non-interactive # Headless full install
|
||||
./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"
|
||||
|
||||
```bash
|
||||
@@ -336,7 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
@@ -373,7 +376,14 @@ sudo usermod -a -G bluetooth $USER
|
||||
|
||||
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||
|
||||
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.
|
||||
On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
|
||||
|
||||
```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)
|
||||
|
||||
|
||||
+14
-5
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
|
||||
| `listening` | Listening post |
|
||||
| `spystations` | Spy stations |
|
||||
| `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
|
||||
|
||||
The navigation is organized into groups:
|
||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||
- **Wireless**: WiFi, Bluetooth
|
||||
- **Security**: TSCM
|
||||
- **Space**: Satellite, ISS SSTV
|
||||
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||
- **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
|
||||
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
||||
|
||||
---
|
||||
|
||||
|
||||
+72
-2
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
@@ -239,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
|
||||
- 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
|
||||
@@ -358,6 +377,39 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
- 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
|
||||
@@ -499,10 +551,28 @@ INTERCEPT can be configured via environment variables:
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||
|
||||
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
||||
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
+67
-23
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">25+</span>
|
||||
<span class="stat-value">30+</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -61,72 +61,78 @@
|
||||
|
||||
<div class="carousel-filters">
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="sdr">SDR / RF</button>
|
||||
<button class="filter-btn" data-filter="aviation">Aviation & Maritime</button>
|
||||
<button class="filter-btn" data-filter="space">Space & Satellite</button>
|
||||
<button class="filter-btn" data-filter="wireless">Wireless & Security</button>
|
||||
<button class="filter-btn" data-filter="signals">Signals</button>
|
||||
<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="sdr">
|
||||
<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>
|
||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="sdr">
|
||||
<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>
|
||||
<h3>433MHz Sensors</h3>
|
||||
<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="sdr">
|
||||
<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="sdr">
|
||||
<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="sdr">
|
||||
<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="sdr">
|
||||
<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="sdr">
|
||||
<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="sdr">
|
||||
<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 rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="aviation">
|
||||
<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>
|
||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="aviation">
|
||||
<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>
|
||||
<h3>ACARS</h3>
|
||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="aviation">
|
||||
<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>
|
||||
<h3>VDL2</h3>
|
||||
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="aviation">
|
||||
<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>
|
||||
<h3>Vessel Tracking</h3>
|
||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||
@@ -152,10 +158,25 @@
|
||||
<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>
|
||||
@@ -172,6 +193,11 @@
|
||||
<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>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
@@ -191,6 +217,11 @@
|
||||
<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>
|
||||
</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>
|
||||
<h3>System Health</h3>
|
||||
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||
</div>
|
||||
@@ -257,6 +288,10 @@
|
||||
<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>
|
||||
@@ -265,6 +300,14 @@
|
||||
<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>
|
||||
@@ -292,10 +335,10 @@
|
||||
<div class="code-block">
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py</code></pre>
|
||||
./setup.sh # Interactive wizard with install profiles
|
||||
sudo ./start.sh</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="install-card">
|
||||
@@ -303,7 +346,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
|
||||
<div class="code-block">
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker compose up -d</code></pre>
|
||||
docker compose --profile basic up -d --build</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||
</div>
|
||||
@@ -312,6 +355,7 @@ docker compose up -d</code></pre>
|
||||
<div class="post-install">
|
||||
<p>After starting, open <code>http://localhost:5050</code> in your browser.</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>
|
||||
</section>
|
||||
@@ -404,7 +448,7 @@ docker compose up -d</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download sample NOAA APT recordings for testing the weather satellite
|
||||
# test-decode feature. These are FM-demodulated audio WAV files.
|
||||
#
|
||||
# Usage:
|
||||
# ./download-weather-sat-samples.sh
|
||||
# docker exec intercept /app/download-weather-sat-samples.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
|
||||
mkdir -p "$SAMPLE_DIR"
|
||||
|
||||
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
|
||||
|
||||
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
|
||||
# Source: https://github.com/martinber/noaa-apt
|
||||
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
|
||||
echo " -> noaa_apt_argentina.wav (18 MB) ..."
|
||||
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
|
||||
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
|
||||
else
|
||||
echo " -> noaa_apt_argentina.wav (already exists)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Test decode with:"
|
||||
echo " Satellite: NOAA-18"
|
||||
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
|
||||
echo " Sample rate: 11025 Hz"
|
||||
#!/usr/bin/env bash
|
||||
# Download sample NOAA APT recordings for testing the weather satellite
|
||||
# test-decode feature. These are FM-demodulated audio WAV files.
|
||||
#
|
||||
# Usage:
|
||||
# ./download-weather-sat-samples.sh
|
||||
# docker exec intercept /app/download-weather-sat-samples.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
|
||||
mkdir -p "$SAMPLE_DIR"
|
||||
|
||||
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
|
||||
|
||||
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
|
||||
# Source: https://github.com/martinber/noaa-apt
|
||||
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
|
||||
echo " -> noaa_apt_argentina.wav (18 MB) ..."
|
||||
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
|
||||
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
|
||||
else
|
||||
echo " -> noaa_apt_argentina.wav (already exists)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Test decode with:"
|
||||
echo " Satellite: NOAA-18"
|
||||
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
|
||||
echo " Sample rate: 11025 Hz"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Gunicorn configuration for INTERCEPT."""
|
||||
|
||||
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):
|
||||
try:
|
||||
_orig(self)
|
||||
except AssertionError:
|
||||
pass
|
||||
|
||||
_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
-5
@@ -2860,11 +2860,22 @@ class ModeManager:
|
||||
pass
|
||||
logger.info("APRS reader stopped")
|
||||
|
||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||
"""Parse APRS packet from direwolf or multimon-ng."""
|
||||
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
||||
if not match:
|
||||
return None
|
||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||
"""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)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
callsign = match.group(1)
|
||||
path = match.group(2)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.19.0"
|
||||
version = "2.24.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -44,3 +44,10 @@ cryptography>=41.0.0
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
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
|
||||
|
||||
+45
-31
@@ -2,39 +2,46 @@
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .acars import acars_bp
|
||||
from .adsb import adsb_bp
|
||||
from .ais import ais_bp
|
||||
from .dsc import dsc_bp
|
||||
from .acars import acars_bp
|
||||
from .vdl2 import vdl2_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 .weather_sat import weather_sat_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .dmr import dmr_bp
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
from .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 .ook import ook_bp
|
||||
from .offline import offline_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 .settings import settings_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 .wefax import wefax_bp
|
||||
from .websdr import websdr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -53,7 +60,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
@@ -63,12 +70,19 @@ def register_blueprints(app):
|
||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
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
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+84
-45
@@ -13,33 +13,35 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
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.sdr import SDRFactory, SDRType
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
PROCESS_START_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
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.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')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
# Default VHF ACARS frequencies (MHz) - North America primary
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
'131.550', # Primary worldwide / North America
|
||||
'130.025', # North America secondary
|
||||
'129.125', # North America tertiary
|
||||
'131.725', # North America (major US carriers)
|
||||
'131.825', # North America (major US carriers)
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
@@ -48,6 +50,7 @@ acars_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
acars_active_device: int | None = None
|
||||
acars_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
@@ -123,12 +126,27 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
data['type'] = 'acars'
|
||||
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
|
||||
acars_message_count += 1
|
||||
acars_last_message_time = time.time()
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
get_flight_correlator().add_acars_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -147,7 +165,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
@@ -163,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
app_module.acars_process = None
|
||||
# Release SDR device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
@@ -196,7 +215,7 @@ def acars_status() -> Response:
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time, acars_active_device
|
||||
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
@@ -223,9 +242,12 @@ def start_acars() -> Response:
|
||||
except ValueError as e:
|
||||
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
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -234,6 +256,7 @@ def start_acars() -> Response:
|
||||
}), 409
|
||||
|
||||
acars_active_device = device_int
|
||||
acars_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
@@ -251,8 +274,6 @@ def start_acars() -> Response:
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -339,14 +360,17 @@ def start_acars() -> Response:
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = f'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(f"acarsdec stderr:\n{stderr}")
|
||||
error_msg = 'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:500]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
@@ -371,8 +395,9 @@ def start_acars() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -380,7 +405,7 @@ def start_acars() -> Response:
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
@@ -401,8 +426,9 @@ def stop_acars() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -410,37 +436,50 @@ def stop_acars() -> Response:
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.acars_queue,
|
||||
channel_key='acars',
|
||||
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
|
||||
|
||||
|
||||
@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')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get default ACARS frequencies."""
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
|
||||
+706
-86
File diff suppressed because it is too large
Load Diff
+76
-28
@@ -18,7 +18,7 @@ import app as app_module
|
||||
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.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
@@ -44,6 +44,7 @@ ais_connected = False
|
||||
ais_messages_received = 0
|
||||
ais_last_message_time = None
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type: str | None = None
|
||||
_ais_error_logged = True
|
||||
|
||||
# Common installation paths for AIS-catcher
|
||||
@@ -79,6 +80,7 @@ def parse_ais_stream(port: int):
|
||||
_ais_error_logged = True
|
||||
|
||||
while ais_running:
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||
@@ -124,20 +126,33 @@ def parse_ais_stream(port: int):
|
||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||
for mmsi in pending_updates:
|
||||
if mmsi in app_module.ais_vessels:
|
||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||
try:
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**app_module.ais_vessels[mmsi]
|
||||
**_vessel_snap
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Geofence check
|
||||
_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
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
ais_connected = False
|
||||
except OSError as e:
|
||||
ais_connected = False
|
||||
@@ -145,6 +160,12 @@ def parse_ais_stream(port: int):
|
||||
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||
_ais_error_logged = True
|
||||
time.sleep(AIS_RECONNECT_DELAY)
|
||||
finally:
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ais_connected = False
|
||||
logger.info("AIS stream parser stopped")
|
||||
@@ -282,6 +303,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
||||
# Timestamp
|
||||
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
|
||||
|
||||
|
||||
@@ -326,7 +357,7 @@ def ais_status():
|
||||
@ais_bp.route('/start', methods=['POST'])
|
||||
def start_ais():
|
||||
"""Start AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if ais_running:
|
||||
@@ -373,7 +404,7 @@ def start_ais():
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -412,20 +443,23 @@ def start_ais():
|
||||
|
||||
if app_module.ais_process.poll() is not None:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
if app_module.ais_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_output:
|
||||
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
|
||||
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
error_msg += f' Error: {stderr_output[:500]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
ais_running = True
|
||||
ais_active_device = device
|
||||
ais_active_sdr_type = sdr_type_str
|
||||
|
||||
# Start TCP parser thread
|
||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||
@@ -439,7 +473,7 @@ def start_ais():
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -447,7 +481,7 @@ def start_ais():
|
||||
@ais_bp.route('/stop', methods=['POST'])
|
||||
def stop_ais():
|
||||
"""Stop AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if app_module.ais_process:
|
||||
@@ -466,10 +500,11 @@ def stop_ais():
|
||||
|
||||
# Release device from registry
|
||||
if ais_active_device is not None:
|
||||
app_module.release_sdr_device(ais_active_device)
|
||||
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
|
||||
|
||||
ais_running = False
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type = None
|
||||
|
||||
app_module.ais_vessels.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
@@ -478,34 +513,47 @@ def stop_ais():
|
||||
@ais_bp.route('/stream')
|
||||
def stream_ais():
|
||||
"""SSE stream for AIS vessels."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('ais', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('ais', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.ais_queue,
|
||||
channel_key='ais',
|
||||
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
|
||||
|
||||
|
||||
@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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'ais_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
+459
-214
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -14,27 +16,34 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from subprocess import PIPE, STDOUT
|
||||
from typing import Generator, Optional
|
||||
from typing import Any, Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# Track which SDR device is being used
|
||||
aprs_active_device: int | None = None
|
||||
aprs_active_sdr_type: str | None = None
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
@@ -47,6 +56,8 @@ APRS_FREQUENCIES = {
|
||||
'brazil': '145.570',
|
||||
'japan': '144.640',
|
||||
'china': '144.640',
|
||||
'iss': '145.825',
|
||||
'sonate2': '145.825',
|
||||
}
|
||||
|
||||
# Statistics
|
||||
@@ -73,19 +84,19 @@ def find_multimon_ng() -> Optional[str]:
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
|
||||
|
||||
# Path to direwolf config file
|
||||
@@ -101,12 +112,35 @@ ADEVICE stdin null
|
||||
CHANNEL 0
|
||||
MYCALL N0CALL
|
||||
MODEM 1200
|
||||
FIX_BITS 1
|
||||
AGWPORT 0
|
||||
KISSPORT 0
|
||||
"""
|
||||
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
||||
f.write(config)
|
||||
return DIREWOLF_CONFIG_PATH
|
||||
|
||||
|
||||
def normalize_aprs_output_line(line: str) -> str:
|
||||
"""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) -> Optional[dict]:
|
||||
"""Parse APRS packet into structured data.
|
||||
|
||||
@@ -123,10 +157,15 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||
- User-defined formats
|
||||
"""
|
||||
try:
|
||||
raw_packet = normalize_aprs_output_line(raw_packet)
|
||||
if not raw_packet:
|
||||
return None
|
||||
|
||||
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
||||
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
||||
|
||||
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||
# Source callsigns can include tactical suffixes like "/1" on some stations.
|
||||
match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
@@ -442,6 +481,109 @@ def parse_position(data: str) -> Optional[dict]:
|
||||
|
||||
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:
|
||||
logger.debug(f"Failed to parse position: {e}")
|
||||
|
||||
@@ -1307,19 +1449,23 @@ def should_send_meter_update(level: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
"""Stream decoded APRS packets and audio level meter to queue.
|
||||
|
||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
||||
Reads from a PTY master fd to get line-buffered output from the decoder,
|
||||
avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
|
||||
to poll the PTY (same pattern as pager.py).
|
||||
|
||||
Outputs two types of messages to the queue:
|
||||
- type='aprs': Decoded APRS packets
|
||||
- type='meter': Audio level meter readings (rate-limited)
|
||||
"""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global _last_meter_time, _last_meter_level
|
||||
global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
# 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
|
||||
_last_meter_time = 0.0
|
||||
@@ -1328,80 +1474,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
try:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Read line-by-line in text mode. Empty string '' signals EOF.
|
||||
for line in iter(decoder_process.stdout.readline, ''):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Read from PTY using select() for non-blocking reads.
|
||||
# PTY forces the decoder to line-buffer, so output arrives immediately
|
||||
# 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
|
||||
|
||||
# Check for audio level line first (for signal meter)
|
||||
audio_level = parse_audio_level(line)
|
||||
if audio_level is not None:
|
||||
if should_send_meter_update(audio_level):
|
||||
meter_msg = {
|
||||
'type': 'meter',
|
||||
'level': audio_level,
|
||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
app_module.aprs_queue.put(meter_msg)
|
||||
continue # Audio level lines are not packets
|
||||
if ready:
|
||||
try:
|
||||
data = os.read(master_fd, 1024)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
except OSError:
|
||||
break
|
||||
|
||||
# multimon-ng prefixes decoded packets with "AFSK1200: "
|
||||
if line.startswith('AFSK1200:'):
|
||||
line = line[9:].strip()
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# 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)
|
||||
# Check for audio level line first (for signal meter)
|
||||
audio_level = parse_audio_level(line)
|
||||
if audio_level is not None:
|
||||
if should_send_meter_update(audio_level):
|
||||
meter_msg = {
|
||||
'type': 'meter',
|
||||
'level': audio_level,
|
||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
app_module.aprs_queue.put(meter_msg)
|
||||
continue # Audio level lines are not packets
|
||||
|
||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||
line = normalize_aprs_output_line(line)
|
||||
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
|
||||
# Update station data
|
||||
if callsign:
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet.get('lat'),
|
||||
'lon': packet.get('lon'),
|
||||
'symbol': packet.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
# Update station data, preserving last known coordinates when
|
||||
# packets do not contain position fields.
|
||||
if callsign:
|
||||
existing = aprs_stations.get(callsign, {})
|
||||
packet_lat = packet.get('lat')
|
||||
packet_lon = packet.get('lon')
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
|
||||
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
|
||||
'symbol': packet.get('symbol') or existing.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'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]
|
||||
|
||||
# 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} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
# 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} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global aprs_active_device
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1413,29 +1593,30 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
||||
if my_device is not None and aprs_active_device == my_device:
|
||||
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/status')
|
||||
@@ -1463,11 +1644,29 @@ 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 jsonify({
|
||||
'status': 'success',
|
||||
'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'])
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global aprs_active_device
|
||||
global aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
@@ -1476,12 +1675,12 @@ def start_aprs() -> Response:
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
@@ -1489,41 +1688,47 @@ def start_aprs() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = 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)
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
||||
if not rtl_tcp_host:
|
||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = device
|
||||
aprs_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
@@ -1545,29 +1750,39 @@ def start_aprs() -> Response:
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
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 jsonify({'status': 'error', 'message': 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)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
@@ -1620,19 +1835,25 @@ def start_aprs() -> Response:
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||
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.
|
||||
# Use text mode with line buffering for reliable line-by-line reading.
|
||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||
# stdout/stderr go to the PTY slave so output is line-buffered.
|
||||
decoder_process = subprocess.Popen(
|
||||
decoder_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=PIPE,
|
||||
stderr=STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=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.
|
||||
# This ensures proper EOF propagation when rtl_fm terminates.
|
||||
rtl_process.stdout.close()
|
||||
@@ -1649,68 +1870,89 @@ def start_aprs() -> Response:
|
||||
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
||||
except Exception:
|
||||
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})'
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:200]}'
|
||||
error_msg += f': {stderr_output[:500]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = 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 ''
|
||||
# Decoder exited early - capture any output from PTY
|
||||
error_output = ''
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 0.5)
|
||||
if ready:
|
||||
raw = os.read(master_fd, 500)
|
||||
error_output = raw.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f'{decoder_name} failed to start'
|
||||
if error_output:
|
||||
error_msg += f': {error_output}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store references for status checks and cleanup
|
||||
app_module.aprs_process = decoder_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
|
||||
thread = threading.Thread(
|
||||
target=stream_aprs_output,
|
||||
args=(rtl_process, decoder_process),
|
||||
args=(master_fd, rtl_process, decoder_process),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
global aprs_active_device
|
||||
global aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
@@ -1736,14 +1978,23 @@ def stop_aprs() -> Response:
|
||||
except Exception as 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:
|
||||
try:
|
||||
os.close(app_module.aprs_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_master_fd = None
|
||||
|
||||
app_module.aprs_process = None
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -1751,25 +2002,19 @@ def stop_aprs() -> Response:
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.aprs_queue,
|
||||
channel_key='aprs',
|
||||
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
|
||||
|
||||
+20
-27
@@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, Response
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
@@ -553,30 +553,23 @@ def get_bt_devices():
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
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
|
||||
|
||||
@@ -261,7 +261,7 @@ def start_scan():
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'status': 'already_scanning',
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
|
||||
+69
-39
@@ -33,16 +33,18 @@ def start_session():
|
||||
"""
|
||||
Start a locate session.
|
||||
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
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.
|
||||
@@ -50,19 +52,33 @@ def start_session():
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Build target
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
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]):
|
||||
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
|
||||
if not any([
|
||||
target.mac_address,
|
||||
target.name_pattern,
|
||||
target.irk_hex,
|
||||
target.device_id,
|
||||
target.device_key,
|
||||
target.fingerprint_id,
|
||||
]):
|
||||
return jsonify({
|
||||
'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()
|
||||
@@ -93,14 +109,27 @@ def start_session():
|
||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||
)
|
||||
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
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 jsonify({
|
||||
'status': 'error',
|
||||
'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 jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Failed to start locate session',
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||
@@ -114,17 +143,18 @@ def stop_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:
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
return jsonify(session.get_status())
|
||||
'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'])
|
||||
|
||||
+56
-32
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
@@ -36,10 +37,28 @@ from utils.trilateration import (
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent data queue for combined SSE stream
|
||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -625,19 +644,16 @@ def ingest_push_data():
|
||||
received_at=data.get('received_at')
|
||||
)
|
||||
|
||||
# Emit to SSE stream
|
||||
try:
|
||||
agent_data_queue.put_nowait({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'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")
|
||||
# Emit to SSE stream (fanout to all connected clients)
|
||||
_broadcast_agent_data({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'accepted',
|
||||
@@ -674,27 +690,35 @@ def get_payloads():
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/stream/all')
|
||||
def stream_all_agents():
|
||||
def stream_all_agents():
|
||||
"""
|
||||
Combined SSE stream for data from all agents.
|
||||
|
||||
This endpoint streams push data as it arrives from agents.
|
||||
Each message is tagged with agent_id and agent_name.
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = agent_data_queue.get(timeout=1.0)
|
||||
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
|
||||
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]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=1.0)
|
||||
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
|
||||
finally:
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.discard(client_queue)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
|
||||
-758
@@ -1,758 +0,0 @@
|
||||
"""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.event_pipeline import process_event
|
||||
from utils.process import register_process, unregister_process
|
||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
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_has_audio = False # True when ffmpeg available and dsd outputs audio
|
||||
dmr_lock = threading.Lock()
|
||||
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_active_device: Optional[int] = None
|
||||
|
||||
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
|
||||
# active ffmpeg stdin sinks when streaming clients are connected.
|
||||
# This prevents dsd-fme from blocking on stdout (which would also
|
||||
# freeze stderr / text data output).
|
||||
_ffmpeg_sinks: set[object] = set()
|
||||
_ffmpeg_sinks_lock = threading.Lock()
|
||||
|
||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||
|
||||
# Classic dsd flags
|
||||
_DSD_PROTOCOL_FLAGS = {
|
||||
'auto': [],
|
||||
'dmr': ['-fd'],
|
||||
'p25': ['-fp'],
|
||||
'nxdn': ['-fn'],
|
||||
'dstar': ['-fi'],
|
||||
'provoice': ['-fv'],
|
||||
}
|
||||
|
||||
# dsd-fme remapped several flags from classic DSD:
|
||||
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
|
||||
# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25),
|
||||
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
|
||||
# -ft = XDMA multi-protocol decoder
|
||||
_DSD_FME_PROTOCOL_FLAGS = {
|
||||
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
|
||||
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
|
||||
'nxdn': ['-fn'], # NXDN96
|
||||
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
|
||||
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||
}
|
||||
|
||||
# Modulation hints: force C4FM for protocols that use it, improving
|
||||
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||
_DSD_FME_MODULATION = {
|
||||
'dmr': ['-mc'], # C4FM
|
||||
'nxdn': ['-mc'], # C4FM
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 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 find_rx_fm() -> str | None:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def parse_dsd_output(line: str) -> dict | None:
|
||||
"""Parse a line of DSD stderr output into a structured event.
|
||||
|
||||
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.)
|
||||
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
|
||||
# characters (│, ─) as column separators in DATA lines, so we must not
|
||||
# discard lines that also contain alphanumeric content.
|
||||
stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line)
|
||||
if not stripped_of_box:
|
||||
return None
|
||||
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
||||
return None
|
||||
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
|
||||
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||
sync_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"
|
||||
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
|
||||
tg_match = re.search(
|
||||
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
|
||||
)
|
||||
if tg_match:
|
||||
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
|
||||
|
||||
# 100ms of silence at 8kHz 16-bit mono = 1600 bytes
|
||||
_SILENCE_CHUNK = b'\x00' * 1600
|
||||
|
||||
|
||||
def _register_audio_sink(sink: object) -> None:
|
||||
"""Register an ffmpeg stdin sink for mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.add(sink)
|
||||
|
||||
|
||||
def _unregister_audio_sink(sink: object) -> None:
|
||||
"""Remove an ffmpeg stdin sink from mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.discard(sink)
|
||||
|
||||
|
||||
def _get_audio_sinks() -> tuple[object, ...]:
|
||||
"""Snapshot current audio sinks for lock-free iteration."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
return tuple(_ffmpeg_sinks)
|
||||
|
||||
|
||||
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
|
||||
"""Terminate and unregister a subprocess if present."""
|
||||
if not proc:
|
||||
return
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
|
||||
|
||||
def _reset_runtime_state(*, release_device: bool) -> None:
|
||||
"""Reset process + runtime state and optionally release SDR ownership."""
|
||||
global dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
_stop_process(dmr_dsd_process)
|
||||
_stop_process(dmr_rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
|
||||
if release_device and dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
|
||||
|
||||
def _dsd_audio_mux(dsd_stdout):
|
||||
"""Mux thread: sole reader of dsd-fme stdout.
|
||||
|
||||
Always drains dsd-fme's audio output to prevent the process from
|
||||
blocking on stdout writes (which would also freeze stderr / text
|
||||
data). When streaming clients are connected, forwards data to all
|
||||
active ffmpeg stdin sinks with silence fill during voice gaps.
|
||||
"""
|
||||
try:
|
||||
while dmr_running:
|
||||
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
|
||||
if ready:
|
||||
data = os.read(dsd_stdout.fileno(), 4096)
|
||||
if not data:
|
||||
break
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(data)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
_unregister_audio_sink(sink)
|
||||
else:
|
||||
# No audio from decoder — feed silence if client connected
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(_SILENCE_CHUNK)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
_unregister_audio_sink(sink)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _queue_put(event: dict):
|
||||
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||
try:
|
||||
dmr_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
dmr_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||
"""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
|
||||
|
||||
logger.debug("DSD raw: %s", text)
|
||||
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
|
||||
global dmr_has_audio
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
# Capture exit info for diagnostics
|
||||
rc = dsd_process.poll()
|
||||
reason = 'stopped'
|
||||
detail = ''
|
||||
if rc is not None and rc != 0:
|
||||
reason = 'crashed'
|
||||
try:
|
||||
remaining = dsd_process.stderr.read(1024)
|
||||
if remaining:
|
||||
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||
# Cleanup decoder + demod processes
|
||||
_stop_process(dsd_process)
|
||||
_stop_process(rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||
# Release SDR device
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
logger.info("DSD stream thread stopped")
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@dmr_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
dsd_path, _ = find_dsd()
|
||||
rtl_fm = find_rtl_fm()
|
||||
rx_fm = find_rx_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
return jsonify({
|
||||
'dsd': dsd_path is not None,
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'rx_fm': rx_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'available': dsd_path is not None and (rtl_fm is not None or rx_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
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
dsd_path, is_fme = find_dsd()
|
||||
if not dsd_path:
|
||||
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = validate_frequency(data.get('frequency', 462.5625))
|
||||
gain = int(validate_gain(data.get('gain', 40)))
|
||||
device = validate_device_index(data.get('device', 0))
|
||||
protocol = str(data.get('protocol', 'auto')).lower()
|
||||
ppm = validate_ppm(data.get('ppm', 0))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if not find_rtl_fm():
|
||||
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
|
||||
else:
|
||||
if not find_rx_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 503
|
||||
|
||||
# Clear stale queue
|
||||
try:
|
||||
while True:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Reserve running state before we start claiming resources/processes
|
||||
# so concurrent /start requests cannot race each other.
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
dmr_running = True
|
||||
dmr_has_audio = False
|
||||
|
||||
# Claim SDR device — use protocol name so the device panel shows
|
||||
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||
error = app_module.claim_sdr_device(device, mode_label)
|
||||
if error:
|
||||
with dmr_lock:
|
||||
dmr_running = False
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
dmr_active_device = device
|
||||
|
||||
# Build FM demodulation command via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=48000,
|
||||
gain=float(gain) if gain > 0 else None,
|
||||
ppm=int(ppm) if ppm != 0 else None,
|
||||
modulation='fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Keep squelch fully open for digital bitstreams.
|
||||
rtl_cmd.extend(['-l', '0'])
|
||||
except Exception as e:
|
||||
_reset_runtime_state(release_device=True)
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build DSD command
|
||||
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
|
||||
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
|
||||
# If ffmpeg is unavailable, fall back to discarding audio.
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if ffmpeg_path:
|
||||
audio_out = '-'
|
||||
else:
|
||||
audio_out = 'null' if is_fme else '-'
|
||||
logger.warning("ffmpeg not found — audio streaming disabled, data-only mode")
|
||||
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
|
||||
if is_fme:
|
||||
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
|
||||
# Event log to stderr so we capture TG/Source/Voice data that
|
||||
# dsd-fme may not output on stderr by default.
|
||||
dsd_cmd.extend(['-J', '/dev/stderr'])
|
||||
# Relax CRC checks for marginal signals — lets more frames
|
||||
# through at the cost of occasional decode errors.
|
||||
if data.get('relaxCrc', False):
|
||||
dsd_cmd.append('-F')
|
||||
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)
|
||||
|
||||
# DSD stdout → PIPE when ffmpeg available (audio pipeline),
|
||||
# otherwise DEVNULL (data-only mode)
|
||||
dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL
|
||||
dmr_dsd_process = subprocess.Popen(
|
||||
dsd_cmd,
|
||||
stdin=dmr_rtl_process.stdout,
|
||||
stdout=dsd_stdout,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
register_process(dmr_dsd_process)
|
||||
|
||||
# Allow rtl_fm to send directly to dsd
|
||||
dmr_rtl_process.stdout.close()
|
||||
|
||||
# Start mux thread: always drains dsd-fme stdout to prevent the
|
||||
# process from blocking (which would freeze stderr / text data).
|
||||
# ffmpeg is started lazily per-client in /dmr/audio/stream.
|
||||
if ffmpeg_path and dmr_dsd_process.stdout:
|
||||
dmr_has_audio = True
|
||||
threading.Thread(
|
||||
target=_dsd_audio_mux,
|
||||
args=(dmr_dsd_process.stdout,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
rtl_rc = dmr_rtl_process.poll()
|
||||
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}")
|
||||
# Terminate surviving processes and release resources.
|
||||
_reset_runtime_state(release_device=True)
|
||||
# Surface a clear error to the user
|
||||
detail = rtl_err.strip() or dsd_err.strip()
|
||||
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||
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_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,
|
||||
'sdr_type': sdr_type.value,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start DMR: {e}")
|
||||
_reset_runtime_state(release_device=True)
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@dmr_bp.route('/stop', methods=['POST'])
|
||||
def stop_dmr() -> Response:
|
||||
"""Stop digital voice decoding."""
|
||||
with dmr_lock:
|
||||
_reset_runtime_state(release_device=True)
|
||||
|
||||
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,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/audio/stream')
|
||||
def stream_dmr_audio() -> Response:
|
||||
"""Stream decoded digital voice audio as WAV.
|
||||
|
||||
Starts a per-client ffmpeg encoder. The global mux thread
|
||||
(_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while
|
||||
the client is connected, and discards audio otherwise. This avoids
|
||||
the pipe-buffer deadlock that occurs when ffmpeg is started at
|
||||
decoder launch (its stdout fills up before any HTTP client reads
|
||||
it, back-pressuring the entire pipeline and freezing stderr/text
|
||||
data output).
|
||||
"""
|
||||
if not dmr_running or not dmr_has_audio:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
return Response(b'', mimetype='audio/wav', status=503)
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path, '-hide_banner', '-loglevel', 'error',
|
||||
'-fflags', 'nobuffer', '-flags', 'low_delay',
|
||||
'-probesize', '32', '-analyzeduration', '0',
|
||||
'-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0',
|
||||
'-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1',
|
||||
]
|
||||
audio_proc = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
# Drain ffmpeg stderr to prevent blocking
|
||||
threading.Thread(
|
||||
target=lambda p: [None for _ in p.stderr],
|
||||
args=(audio_proc,), daemon=True,
|
||||
).start()
|
||||
|
||||
if audio_proc.stdin:
|
||||
_register_audio_sink(audio_proc.stdin)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while dmr_running and audio_proc.poll() is None:
|
||||
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_proc.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
else:
|
||||
if audio_proc.poll() is not None:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"DMR audio stream error: {e}")
|
||||
finally:
|
||||
# Disconnect mux → ffmpeg, then clean up
|
||||
if audio_proc.stdin:
|
||||
_unregister_audio_sink(audio_proc.stdin)
|
||||
try:
|
||||
audio_proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
audio_proc.terminate()
|
||||
audio_proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
audio_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='audio/wav',
|
||||
headers={
|
||||
'Content-Type': 'audio/wav',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dmr_bp.route('/stream')
|
||||
def stream_dmr() -> Response:
|
||||
"""SSE stream for DMR decoder events."""
|
||||
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()
|
||||
try:
|
||||
process_event('dmr', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
+79
-52
@@ -35,9 +35,14 @@ from utils.database import (
|
||||
get_dsc_alert_summary,
|
||||
)
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_gain,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.process import register_process, unregister_process
|
||||
@@ -51,6 +56,7 @@ dsc_running = False
|
||||
|
||||
# Track which device is being used
|
||||
dsc_active_device: int | None = None
|
||||
dsc_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _get_dsc_decoder_path() -> str | None:
|
||||
@@ -171,7 +177,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
'error': str(e)
|
||||
})
|
||||
finally:
|
||||
global dsc_active_device
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
@@ -197,8 +203,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
app_module.dsc_rtl_process = None
|
||||
# Release SDR device
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
|
||||
def _store_critical_alert(msg: dict) -> None:
|
||||
@@ -331,18 +338,32 @@ def start_decoding() -> Response:
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
# Check if device is available using centralized registry
|
||||
global dsc_active_device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
dsc_active_device = device_int
|
||||
# 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)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
dsc_active_device = device_int
|
||||
dsc_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
@@ -351,22 +372,32 @@ def start_decoding() -> Response:
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build rtl_fm command
|
||||
rtl_fm_path = tools['rtl_fm']['path']
|
||||
# Build rtl_fm command via SDR abstraction layer
|
||||
decoder_path = tools['dsc_decoder']['path']
|
||||
|
||||
# rtl_fm command for DSC decoding
|
||||
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||
'-s', str(DSC_SAMPLE_RATE),
|
||||
'-d', str(device),
|
||||
'-g', str(gain),
|
||||
'-M', 'fm', # FM demodulation
|
||||
'-l', '0', # No squelch for DSC
|
||||
'-E', 'dc' # DC blocking filter
|
||||
]
|
||||
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 jsonify({'status': 'error', 'message': 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=int(device))
|
||||
|
||||
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_cmd = [decoder_path]
|
||||
@@ -440,8 +471,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Tool not found: {e.filename}'
|
||||
@@ -458,8 +490,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
logger.error(f"Failed to start DSC decoder: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -470,7 +503,7 @@ def start_decoding() -> Response:
|
||||
@dsc_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
"""Stop DSC decoder."""
|
||||
global dsc_running, dsc_active_device
|
||||
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
||||
|
||||
with app_module.dsc_lock:
|
||||
if not app_module.dsc_process:
|
||||
@@ -509,8 +542,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -518,26 +552,19 @@ def stop_decoding() -> Response:
|
||||
@dsc_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
"""SSE stream for real-time DSC messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('dsc', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.dsc_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('dsc', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.dsc_queue,
|
||||
channel_key='dsc',
|
||||
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'
|
||||
|
||||
+43
-46
@@ -21,7 +21,7 @@ from utils.gps import (
|
||||
stop_gpsd_daemon,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
|
||||
@@ -65,14 +65,17 @@ def auto_connect_gps():
|
||||
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
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
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
@@ -204,21 +207,22 @@ def get_position():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
@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:
|
||||
@@ -228,26 +232,19 @@ def get_satellites():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = _gps_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(data)
|
||||
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['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
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
|
||||
|
||||
+861
-389
File diff suppressed because it is too large
Load Diff
+31
-22
@@ -17,7 +17,7 @@ from typing import Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.meshtastic import (
|
||||
get_meshtastic_client,
|
||||
start_meshtastic,
|
||||
@@ -453,8 +453,8 @@ def get_messages():
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
"""
|
||||
SSE stream of Meshtastic messages.
|
||||
|
||||
@@ -469,25 +469,18 @@ def stream_messages():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = _mesh_queue.get(timeout=1)
|
||||
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['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_mesh_queue,
|
||||
channel_key='meshtastic',
|
||||
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
|
||||
|
||||
|
||||
@@ -1051,3 +1044,19 @@ def request_store_forward():
|
||||
'status': 'error',
|
||||
'message': error or 'Failed to request S&F history'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/topology')
|
||||
def mesh_topology():
|
||||
"""Return mesh network topology graph."""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
if not client or not client.is_running:
|
||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'topology': client.get_topology(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
"""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
|
||||
|
||||
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 jsonify({'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")
|
||||
+1016
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -9,13 +9,14 @@ import os
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
"""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.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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
try:
|
||||
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': f'Invalid timing parameter: {e}'}), 400
|
||||
if min_bits < 1:
|
||||
return jsonify({'status': 'error', 'message': 'min_bits must be >= 1'}), 400
|
||||
if short_pulse < 1 or long_pulse < 1:
|
||||
return jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': 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
|
||||
+63
-34
@@ -24,7 +24,7 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
pager_active_device: int | None = None
|
||||
pager_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
@@ -54,6 +55,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
'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_addr_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
||||
@@ -108,6 +123,20 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
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,
|
||||
@@ -118,7 +147,7 @@ def audio_relay_thread(
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event onto *output_queue*.
|
||||
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
|
||||
@@ -152,6 +181,7 @@ def audio_relay_thread(
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
@@ -205,7 +235,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
@@ -234,13 +264,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
app_module.current_process = None
|
||||
# Release SDR device
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
@@ -269,10 +300,13 @@ def start_decoding() -> Response:
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
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
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -280,14 +314,16 @@ def start_decoding() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
pager_active_device = device_int
|
||||
pager_active_sdr_type = sdr_type_str
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
@@ -312,8 +348,7 @@ def start_decoding() -> Response:
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -428,8 +463,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
@@ -443,14 +479,15 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
@@ -487,8 +524,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -540,28 +578,19 @@ def toggle_logging() -> Response:
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
import json
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
"""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 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.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
|
||||
try:
|
||||
app_module.radiosonde_queue.put_nowait({
|
||||
'type': 'balloon',
|
||||
**balloon,
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
_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:
|
||||
try:
|
||||
balloon['lat'] = float(msg[key])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
for key in ('lon', 'longitude'):
|
||||
if key in msg:
|
||||
try:
|
||||
balloon['lon'] = float(msg[key])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
|
||||
# Altitude (metres)
|
||||
if 'alt' in msg:
|
||||
try:
|
||||
balloon['alt'] = float(msg['alt'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Meteorological data
|
||||
for field in ('temp', 'humidity', 'pressure'):
|
||||
if field in msg:
|
||||
try:
|
||||
balloon[field] = float(msg[field])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Velocity
|
||||
if 'vel_h' in msg:
|
||||
try:
|
||||
balloon['vel_h'] = float(msg['vel_h'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if 'vel_v' in msg:
|
||||
try:
|
||||
balloon['vel_v'] = float(msg['vel_v'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if 'heading' in msg:
|
||||
try:
|
||||
balloon['heading'] = float(msg['heading'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# GPS satellites
|
||||
if 'sats' in msg:
|
||||
try:
|
||||
balloon['sats'] = int(msg['sats'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Battery voltage
|
||||
if 'batt' in msg:
|
||||
try:
|
||||
balloon['batt'] = float(msg['batt'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Frequency
|
||||
if 'freq' in msg:
|
||||
try:
|
||||
balloon['freq'] = float(msg['freq'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
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 jsonify({
|
||||
'status': 'already_running',
|
||||
'message': '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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': 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 jsonify({
|
||||
'status': 'error',
|
||||
'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
|
||||
# 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 jsonify({'status': 'error', 'message': 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 jsonify({
|
||||
'status': 'error',
|
||||
'message': (
|
||||
'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:
|
||||
try:
|
||||
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
||||
'utf-8', errors='ignore'
|
||||
).strip()
|
||||
except Exception:
|
||||
pass
|
||||
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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': 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:
|
||||
try:
|
||||
_udp_socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
_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 jsonify({
|
||||
'status': 'success',
|
||||
'count': len(radiosonde_balloons),
|
||||
'balloons': dict(radiosonde_balloons),
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
@@ -107,3 +108,59 @@ def download_recording(session_id: str):
|
||||
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 jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
|
||||
file_path = Path(rec['file_path'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
if resolved_root not in resolved_file.parents:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||
|
||||
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 jsonify({
|
||||
'status': 'success',
|
||||
'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,
|
||||
})
|
||||
|
||||
+65
-50
@@ -17,7 +17,7 @@ from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
|
||||
@@ -29,6 +29,7 @@ rtl_tcp_lock = threading.Lock()
|
||||
|
||||
# Track which device is being used
|
||||
rtlamr_active_device: int | None = None
|
||||
rtlamr_active_sdr_type: str = 'rtlsdr'
|
||||
|
||||
|
||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
@@ -62,7 +63,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
# Ensure rtlamr process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
@@ -91,19 +92,26 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
app_module.rtlamr_process = None
|
||||
# Release SDR device
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
|
||||
|
||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||
def start_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
||||
|
||||
data = request.json 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
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
@@ -116,7 +124,7 @@ def start_rtlamr() -> Response:
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -125,6 +133,7 @@ def start_rtlamr() -> Response:
|
||||
}), 409
|
||||
|
||||
rtlamr_active_device = device_int
|
||||
rtlamr_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.rtlamr_queue.empty():
|
||||
@@ -138,44 +147,48 @@ def start_rtlamr() -> Response:
|
||||
output_format = data.get('format', 'json')
|
||||
|
||||
# Start rtl_tcp first
|
||||
rtl_tcp_just_started = False
|
||||
rtl_tcp_cmd_str = ''
|
||||
with rtl_tcp_lock:
|
||||
if not rtl_tcp_process:
|
||||
logger.info("Starting rtl_tcp server...")
|
||||
try:
|
||||
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
|
||||
|
||||
|
||||
# Add device index if not 0
|
||||
if device and device != '0':
|
||||
rtl_tcp_cmd.extend(['-d', str(device)])
|
||||
|
||||
|
||||
# Add gain if not auto
|
||||
if gain and gain != '0':
|
||||
rtl_tcp_cmd.extend(['-g', str(gain)])
|
||||
|
||||
|
||||
# Add PPM correction if not 0
|
||||
if ppm and ppm != '0':
|
||||
rtl_tcp_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
|
||||
rtl_tcp_process = subprocess.Popen(
|
||||
rtl_tcp_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_tcp_process)
|
||||
|
||||
# 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)}'})
|
||||
rtl_tcp_just_started = True
|
||||
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||
# Release SDR device on rtl_tcp failure
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
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
|
||||
cmd = [
|
||||
'rtlamr',
|
||||
@@ -238,7 +251,7 @@ def start_rtlamr() -> Response:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||
except Exception as e:
|
||||
@@ -249,38 +262,47 @@ def start_rtlamr() -> Response:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||
def stop_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
|
||||
# Grab process refs inside locks, clear state, then terminate outside
|
||||
rtlamr_proc = None
|
||||
with app_module.rtlamr_lock:
|
||||
if 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()
|
||||
rtlamr_proc = app_module.rtlamr_process
|
||||
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
|
||||
tcp_proc = None
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
try:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtl_tcp_process.kill()
|
||||
tcp_proc = rtl_tcp_process
|
||||
rtl_tcp_process = None
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
if tcp_proc:
|
||||
tcp_proc.terminate()
|
||||
try:
|
||||
tcp_proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
tcp_proc.kill()
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
# Release device from registry
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
@@ -288,26 +310,19 @@ def stop_rtlamr() -> Response:
|
||||
|
||||
@rtlamr_bp.route('/stream_rtlamr')
|
||||
def stream_rtlamr() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.rtlamr_queue,
|
||||
channel_key='rtlamr',
|
||||
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'
|
||||
|
||||
+53
-12
@@ -28,6 +28,17 @@ from utils.validation import validate_latitude, validate_longitude, validate_hou
|
||||
|
||||
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)
|
||||
MAX_RESPONSE_SIZE = 1024 * 1024
|
||||
|
||||
@@ -166,9 +177,11 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
|
||||
@@ -176,7 +189,7 @@ def satellite_dashboard():
|
||||
def predict_passes():
|
||||
"""Calculate satellite passes using skyfield."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
@@ -217,7 +230,7 @@ def predict_passes():
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
t0 = ts.now()
|
||||
@@ -330,7 +343,7 @@ def predict_passes():
|
||||
def get_satellite_position():
|
||||
"""Get real-time positions of satellites."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||
|
||||
@@ -359,7 +372,7 @@ def get_satellite_position():
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
@@ -466,7 +479,8 @@ def refresh_tle_data() -> list:
|
||||
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
||||
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
||||
'METEOR-M 2': 'METEOR-M2',
|
||||
'METEOR-M2 3': 'METEOR-M2-3'
|
||||
'METEOR-M2 3': 'METEOR-M2-3',
|
||||
'METEOR-M2 4': 'METEOR-M2-4'
|
||||
}
|
||||
|
||||
updated = []
|
||||
@@ -588,14 +602,14 @@ def list_tracked_satellites():
|
||||
def add_tracked_satellites_endpoint():
|
||||
"""Add one or more tracked satellites."""
|
||||
global _tle_cache
|
||||
data = request.json
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
# Accept a single satellite dict or a list
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
|
||||
added = 0
|
||||
normalized: list[dict] = []
|
||||
for sat in sat_list:
|
||||
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||
name = sat.get('name', '')
|
||||
@@ -605,19 +619,46 @@ def add_tracked_satellites_endpoint():
|
||||
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||
enabled = sat.get('enabled', True)
|
||||
|
||||
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
|
||||
added += 1
|
||||
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)
|
||||
|
||||
return jsonify({
|
||||
# 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,
|
||||
'satellites': get_tracked_satellites(),
|
||||
})
|
||||
'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'])
|
||||
|
||||
+349
-285
@@ -1,285 +1,349 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
app_module.sensor_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rssi': rssi if rssi is not None else 0,
|
||||
'snr': snr if snr is not None else 0,
|
||||
'noise': noise if noise is not None else 0,
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
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 jsonify({'status': 'error', 'message': 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:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
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
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(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:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
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:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
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
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
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'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
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
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
sensor_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
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 jsonify({'status': 'error', 'message': 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:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
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
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(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:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.sensor_queue,
|
||||
channel_key='sensor',
|
||||
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
|
||||
|
||||
|
||||
@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 jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""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
|
||||
|
||||
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 jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||
|
||||
try:
|
||||
frequency_mhz = float(freq_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||
|
||||
if frequency_mhz <= 0:
|
||||
return jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': '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,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""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
|
||||
|
||||
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 jsonify({'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 jsonify({'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)})
|
||||
+188
-89
@@ -7,15 +7,16 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_sstv_decoder,
|
||||
@@ -27,11 +28,36 @@ logger = get_logger('intercept.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
|
||||
_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
|
||||
sstv_active_device: int | None = None
|
||||
sstv_active_sdr_type: str = 'rtlsdr'
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
@@ -46,6 +72,14 @@ def _progress_callback(data: dict) -> None:
|
||||
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')
|
||||
def get_status():
|
||||
"""
|
||||
@@ -62,6 +96,7 @@ def get_status():
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'iss_frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'image_count': len(decoder.get_images()),
|
||||
'doppler_enabled': decoder.doppler_enabled,
|
||||
}
|
||||
@@ -82,6 +117,7 @@ def start_decoder():
|
||||
JSON body (optional):
|
||||
{
|
||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||
"modulation": "fm", // ISS mode is FM-only
|
||||
"device": 0, // RTL-SDR device index
|
||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||
@@ -106,6 +142,7 @@ def start_decoder():
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
})
|
||||
|
||||
@@ -118,19 +155,38 @@ def start_decoder():
|
||||
|
||||
# Get parameters
|
||||
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)
|
||||
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||
device_index = data.get('device', 0)
|
||||
latitude = data.get('latitude')
|
||||
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
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (100 <= frequency <= 500): # VHF range
|
||||
normalized_frequency = _normalize_iss_frequency(frequency)
|
||||
if normalized_frequency is None:
|
||||
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 100-500 MHz'
|
||||
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||
}), 400
|
||||
frequency = normalized_frequency
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -162,9 +218,9 @@ def start_decoder():
|
||||
longitude = None
|
||||
|
||||
# Claim SDR device
|
||||
global sstv_active_device
|
||||
global sstv_active_device, sstv_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv')
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -178,15 +234,18 @@ def start_decoder():
|
||||
frequency=frequency,
|
||||
device_index=device_index,
|
||||
latitude=latitude,
|
||||
longitude=longitude
|
||||
longitude=longitude,
|
||||
modulation=ISS_SSTV_MODULATION,
|
||||
)
|
||||
|
||||
if success:
|
||||
sstv_active_device = device_int
|
||||
sstv_active_sdr_type = sdr_type_str
|
||||
|
||||
result = {
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'device': device_index,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
}
|
||||
@@ -198,7 +257,7 @@ def start_decoder():
|
||||
return jsonify(result)
|
||||
else:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder'
|
||||
@@ -213,13 +272,13 @@ def stop_decoder():
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
global sstv_active_device
|
||||
global sstv_active_device, sstv_active_sdr_type
|
||||
decoder = get_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
# Release device from registry
|
||||
if sstv_active_device is not None:
|
||||
app_module.release_sdr_device(sstv_active_device)
|
||||
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
|
||||
sstv_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
@@ -392,38 +451,42 @@ def stream_progress():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
progress = _sstv_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sstv', progress, progress.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(progress)
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_queue,
|
||||
channel_key='sstv',
|
||||
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
|
||||
|
||||
|
||||
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')
|
||||
def iss_schedule():
|
||||
"""
|
||||
Get ISS pass schedule for SSTV reception.
|
||||
|
||||
Calculates ISS passes directly using skyfield.
|
||||
Results are cached for 15 minutes per rounded location.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (required)
|
||||
@@ -433,6 +496,8 @@ def iss_schedule():
|
||||
Returns:
|
||||
JSON with ISS pass schedule.
|
||||
"""
|
||||
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
|
||||
|
||||
lat = request.args.get('latitude', type=float)
|
||||
lon = request.args.get('longitude', type=float)
|
||||
hours = request.args.get('hours', 48, type=int)
|
||||
@@ -443,8 +508,18 @@ def iss_schedule():
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 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:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.api import wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from datetime import timedelta
|
||||
from data.satellites import TLE_SATELLITES
|
||||
@@ -457,7 +532,7 @@ def iss_schedule():
|
||||
'message': 'ISS TLE data not available'
|
||||
}), 500
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
@@ -520,13 +595,21 @@ def iss_schedule():
|
||||
|
||||
i += 1
|
||||
|
||||
return jsonify({
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'passes': passes,
|
||||
'count': len(passes),
|
||||
'sstv_frequency': ISS_SSTV_FREQ,
|
||||
'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:
|
||||
return jsonify({
|
||||
@@ -542,13 +625,65 @@ def iss_schedule():
|
||||
}), 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')
|
||||
def iss_position():
|
||||
"""
|
||||
Get current ISS position from real-time API.
|
||||
|
||||
Uses the Open Notify API for accurate real-time position,
|
||||
with fallback to "Where The ISS At" API.
|
||||
Uses the "Where The ISS At" API for accurate real-time position,
|
||||
with fallback to Open Notify API. Raw position is cached for 10 seconds;
|
||||
observer-relative data (elevation/azimuth) is computed per-request.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (optional, for elevation calc)
|
||||
@@ -557,68 +692,32 @@ def iss_position():
|
||||
Returns:
|
||||
JSON with ISS current position.
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
pos = _fetch_iss_position()
|
||||
if pos is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||
}), 503
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': pos['lat'],
|
||||
'lon': pos['lon'],
|
||||
'altitude': pos['altitude'],
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': pos['source'],
|
||||
}
|
||||
|
||||
# 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))
|
||||
# Calculate observer-relative data if location provided
|
||||
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))
|
||||
|
||||
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
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
||||
|
||||
+47
-21
@@ -13,8 +13,9 @@ from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_general_sstv_decoder,
|
||||
@@ -27,6 +28,10 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
|
||||
# Queue for SSE progress streaming
|
||||
_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
|
||||
SSTV_FREQUENCIES = [
|
||||
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
||||
@@ -40,7 +45,7 @@ SSTV_FREQUENCIES = [
|
||||
{'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': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '2 m', 'frequency': 145.500, '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'},
|
||||
]
|
||||
|
||||
@@ -115,6 +120,14 @@ def start_decoder():
|
||||
break
|
||||
|
||||
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')
|
||||
modulation = data.get('modulation')
|
||||
device_index = data.get('device', 0)
|
||||
@@ -150,6 +163,17 @@ def start_decoder():
|
||||
'message': 'Modulation must be fm, usb, or lsb',
|
||||
}), 400
|
||||
|
||||
# Claim SDR device
|
||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
success = decoder.start(
|
||||
@@ -159,6 +183,8 @@ def start_decoder():
|
||||
)
|
||||
|
||||
if success:
|
||||
_sstv_general_active_device = device_int
|
||||
_sstv_general_active_sdr_type = sdr_type_str
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
@@ -166,6 +192,7 @@ def start_decoder():
|
||||
'device': device_index,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
@@ -175,8 +202,14 @@ def start_decoder():
|
||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop general SSTV decoder."""
|
||||
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
||||
decoder = get_general_sstv_decoder()
|
||||
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'})
|
||||
|
||||
|
||||
@@ -266,26 +299,19 @@ def delete_all_images():
|
||||
@sstv_general_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of SSTV decode progress."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv_general', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
progress = _sstv_general_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sstv_general', progress, progress.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(progress)
|
||||
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 = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_general_queue,
|
||||
channel_key='sstv_general',
|
||||
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'
|
||||
|
||||
+12
-7
@@ -10,9 +10,10 @@ import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
@@ -32,10 +33,14 @@ subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
||||
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
process_event('subghz', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_subghz_queue.get_nowait()
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
"""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.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 jsonify({'error': 'No location available'})
|
||||
|
||||
if _requests is None:
|
||||
return jsonify({'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 jsonify({'error': str(exc)})
|
||||
+81
-60
@@ -61,6 +61,7 @@ from utils.tscm.device_identity import (
|
||||
ingest_wifi_dict,
|
||||
)
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
# Import unified Bluetooth scanner helper for TSCM integration
|
||||
try:
|
||||
@@ -629,24 +630,17 @@ def sweep_status():
|
||||
@tscm_bp.route('/sweep/stream')
|
||||
def sweep_stream():
|
||||
"""SSE stream for real-time sweep updates."""
|
||||
def generate():
|
||||
while True:
|
||||
try:
|
||||
if tscm_queue:
|
||||
msg = tscm_queue.get(timeout=1)
|
||||
try:
|
||||
process_event('tscm', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
else:
|
||||
time.sleep(1)
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('tscm', msg, msg.get('type'))
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
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',
|
||||
@@ -1351,7 +1345,7 @@ def _scan_rf_signals(
|
||||
sweep_ranges: list[dict] | None = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Scan for RF signals using SDR (rtl_power).
|
||||
Scan for RF signals using SDR (rtl_power or hackrf_sweep).
|
||||
|
||||
Scans common surveillance frequency bands:
|
||||
- 88-108 MHz: FM broadcast (potential FM bugs)
|
||||
@@ -1381,39 +1375,50 @@ def _scan_rf_signals(
|
||||
|
||||
logger.info(f"Starting RF scan (device={sdr_device})")
|
||||
|
||||
# Detect available SDR devices and sweep tools
|
||||
rtl_power_path = shutil.which('rtl_power')
|
||||
if not rtl_power_path:
|
||||
logger.warning("rtl_power not found in PATH, RF scanning unavailable")
|
||||
hackrf_sweep_path = shutil.which('hackrf_sweep')
|
||||
|
||||
sdr_type = None
|
||||
sweep_tool_path = None
|
||||
|
||||
try:
|
||||
from utils.sdr import SDRFactory
|
||||
from utils.sdr.base import SDRType
|
||||
devices = SDRFactory.detect_devices()
|
||||
rtlsdr_available = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
hackrf_available = any(d.sdr_type == SDRType.HACKRF for d in devices)
|
||||
except ImportError:
|
||||
rtlsdr_available = False
|
||||
hackrf_available = False
|
||||
|
||||
# Pick the best available SDR + sweep tool combo
|
||||
if rtlsdr_available and rtl_power_path:
|
||||
sdr_type = 'rtlsdr'
|
||||
sweep_tool_path = rtl_power_path
|
||||
logger.info(f"Using RTL-SDR with rtl_power at: {rtl_power_path}")
|
||||
elif hackrf_available and hackrf_sweep_path:
|
||||
sdr_type = 'hackrf'
|
||||
sweep_tool_path = hackrf_sweep_path
|
||||
logger.info(f"Using HackRF with hackrf_sweep at: {hackrf_sweep_path}")
|
||||
elif rtl_power_path:
|
||||
# Tool exists but no device detected — try anyway (detection may have failed)
|
||||
sdr_type = 'rtlsdr'
|
||||
sweep_tool_path = rtl_power_path
|
||||
logger.info(f"No SDR detected but rtl_power found, attempting RTL-SDR scan")
|
||||
elif hackrf_sweep_path:
|
||||
sdr_type = 'hackrf'
|
||||
sweep_tool_path = hackrf_sweep_path
|
||||
logger.info(f"No SDR detected but hackrf_sweep found, attempting HackRF scan")
|
||||
|
||||
if not sweep_tool_path:
|
||||
logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)")
|
||||
_emit_event('rf_status', {
|
||||
'status': 'error',
|
||||
'message': 'rtl_power not installed. Install rtl-sdr package for RF scanning.',
|
||||
'message': 'No SDR sweep tool installed. Install rtl-sdr (rtl_power) or HackRF (hackrf_sweep) for RF scanning.',
|
||||
})
|
||||
return signals
|
||||
|
||||
logger.info(f"Found rtl_power at: {rtl_power_path}")
|
||||
|
||||
# Test if RTL-SDR device is accessible
|
||||
rtl_test_path = shutil.which('rtl_test')
|
||||
if rtl_test_path:
|
||||
try:
|
||||
test_result = subprocess.run(
|
||||
[rtl_test_path, '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if 'No supported devices found' in test_result.stderr or test_result.returncode != 0:
|
||||
logger.warning("No RTL-SDR device found")
|
||||
_emit_event('rf_status', {
|
||||
'status': 'error',
|
||||
'message': 'No RTL-SDR device connected. Connect an RTL-SDR dongle for RF scanning.',
|
||||
})
|
||||
return signals
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # Device might be busy, continue anyway
|
||||
except Exception as e:
|
||||
logger.debug(f"rtl_test check failed: {e}")
|
||||
|
||||
# Define frequency bands to scan (in Hz)
|
||||
# Format: (start_freq, end_freq, bin_size, description)
|
||||
scan_bands: list[tuple[int, int, int, str]] = []
|
||||
@@ -1454,7 +1459,7 @@ def _scan_rf_signals(
|
||||
|
||||
try:
|
||||
# Build device argument
|
||||
device_arg = ['-d', str(sdr_device if sdr_device is not None else 0)]
|
||||
device_idx = sdr_device if sdr_device is not None else 0
|
||||
|
||||
# Scan each band and look for strong signals
|
||||
for start_freq, end_freq, bin_size, band_name in scan_bands:
|
||||
@@ -1464,15 +1469,27 @@ def _scan_rf_signals(
|
||||
logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)")
|
||||
|
||||
try:
|
||||
# Run rtl_power for a quick sweep of this band
|
||||
cmd = [
|
||||
rtl_power_path,
|
||||
'-f', f'{start_freq}:{end_freq}:{bin_size}',
|
||||
'-g', '40', # Gain
|
||||
'-i', '1', # Integration interval (1 second)
|
||||
'-1', # Single shot mode
|
||||
'-c', '20%', # Crop 20% of edges
|
||||
] + device_arg + [tmp_path]
|
||||
# Build sweep command based on SDR type
|
||||
if sdr_type == 'hackrf':
|
||||
cmd = [
|
||||
sweep_tool_path,
|
||||
'-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}',
|
||||
'-w', str(bin_size),
|
||||
'-1', # Single sweep
|
||||
]
|
||||
output_mode = 'stdout'
|
||||
else:
|
||||
cmd = [
|
||||
sweep_tool_path,
|
||||
'-f', f'{start_freq}:{end_freq}:{bin_size}',
|
||||
'-g', '40', # Gain
|
||||
'-i', '1', # Integration interval (1 second)
|
||||
'-1', # Single shot mode
|
||||
'-c', '20%', # Crop 20% of edges
|
||||
'-d', str(device_idx),
|
||||
tmp_path,
|
||||
]
|
||||
output_mode = 'file'
|
||||
|
||||
logger.debug(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -1484,9 +1501,14 @@ def _scan_rf_signals(
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"rtl_power returned {result.returncode}: {result.stderr}")
|
||||
logger.warning(f"{os.path.basename(sweep_tool_path)} returned {result.returncode}: {result.stderr}")
|
||||
|
||||
# Parse the CSV output
|
||||
# For HackRF, write stdout CSV data to temp file for unified parsing
|
||||
if output_mode == 'stdout' and result.stdout:
|
||||
with open(tmp_path, 'w') as f:
|
||||
f.write(result.stdout)
|
||||
|
||||
# Parse the CSV output (same format for both rtl_power and hackrf_sweep)
|
||||
if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0:
|
||||
with open(tmp_path, 'r') as f:
|
||||
for line in f:
|
||||
@@ -1494,13 +1516,12 @@ def _scan_rf_signals(
|
||||
if len(parts) >= 7:
|
||||
try:
|
||||
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
|
||||
hz_low = int(parts[2])
|
||||
hz_high = int(parts[3])
|
||||
hz_step = float(parts[4])
|
||||
hz_low = int(parts[2].strip())
|
||||
hz_high = int(parts[3].strip())
|
||||
hz_step = float(parts[4].strip())
|
||||
db_values = [float(x) for x in parts[6:] if x.strip()]
|
||||
|
||||
# Find peaks above noise floor
|
||||
# RTL-SDR dongles have higher noise figures, so use permissive thresholds
|
||||
noise_floor = sum(db_values) / len(db_values) if db_values else -100
|
||||
threshold = noise_floor + 6 # Signal must be 6dB above noise
|
||||
|
||||
|
||||
+122
-52
@@ -2,30 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
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 Generator
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
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.sdr import SDRFactory, SDRType
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.acars_translator import translate_message
|
||||
from utils.constants import (
|
||||
PROCESS_START_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
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.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')
|
||||
|
||||
@@ -44,6 +50,7 @@ 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():
|
||||
@@ -51,15 +58,20 @@ def find_dumpvdl2():
|
||||
return shutil.which('dumpvdl2')
|
||||
|
||||
|
||||
def stream_vdl2_output(process: subprocess.Popen) -> None:
|
||||
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'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
# 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
|
||||
|
||||
@@ -70,12 +82,33 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
||||
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
|
||||
try:
|
||||
get_flight_correlator().add_vdl2_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -94,7 +127,7 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
||||
logger.error(f"VDL2 stream error: {e}")
|
||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
@@ -110,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
||||
app_module.vdl2_process = None
|
||||
# Release SDR device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
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')
|
||||
@@ -143,7 +177,7 @@ def vdl2_status() -> Response:
|
||||
@vdl2_bp.route('/start', methods=['POST'])
|
||||
def start_vdl2() -> Response:
|
||||
"""Start VDL2 decoder."""
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||
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:
|
||||
@@ -170,9 +204,16 @@ def start_vdl2() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': 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')
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -181,6 +222,7 @@ def start_vdl2() -> Response:
|
||||
}), 409
|
||||
|
||||
vdl2_active_device = device_int
|
||||
vdl2_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
# dumpvdl2 expects frequencies in Hz (integers)
|
||||
@@ -199,13 +241,6 @@ def start_vdl2() -> Response:
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build dumpvdl2 command
|
||||
@@ -236,12 +271,28 @@ def start_vdl2() -> Response:
|
||||
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
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 = io.open(master_fd, 'r', 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)
|
||||
@@ -249,14 +300,17 @@ def start_vdl2() -> Response:
|
||||
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)
|
||||
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[:200]}'
|
||||
error_msg += f': {stderr[:500]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
@@ -266,7 +320,7 @@ def start_vdl2() -> Response:
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_vdl2_output,
|
||||
args=(process,),
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
@@ -281,8 +335,9 @@ def start_vdl2() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
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 jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -290,7 +345,7 @@ def start_vdl2() -> Response:
|
||||
@vdl2_bp.route('/stop', methods=['POST'])
|
||||
def stop_vdl2() -> Response:
|
||||
"""Stop VDL2 decoder."""
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if not app_module.vdl2_process:
|
||||
@@ -311,8 +366,9 @@ def stop_vdl2() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
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'})
|
||||
|
||||
@@ -320,30 +376,44 @@ def stop_vdl2() -> Response:
|
||||
@vdl2_bp.route('/stream')
|
||||
def stream_vdl2() -> Response:
|
||||
"""SSE stream for VDL2 messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('vdl2', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.vdl2_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('vdl2', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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 = 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."""
|
||||
|
||||
+847
-386
File diff suppressed because it is too large
Load Diff
+87
-36
@@ -12,12 +12,13 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
|
||||
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
from utils.weather_sat import (
|
||||
get_weather_sat_decoder,
|
||||
is_weather_sat_available,
|
||||
CaptureProgress,
|
||||
WEATHER_SATELLITES,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
@@ -40,6 +41,35 @@ def _progress_callback(progress: CaptureProgress) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _release_weather_sat_device(device_index: int) -> None:
|
||||
"""Release an SDR device only if weather-sat currently owns it."""
|
||||
if device_index < 0:
|
||||
return
|
||||
|
||||
try:
|
||||
import app as app_module
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
owner = None
|
||||
get_status = getattr(app_module, 'get_sdr_device_status', None)
|
||||
if callable(get_status):
|
||||
try:
|
||||
owner = get_status().get(device_index)
|
||||
except Exception:
|
||||
owner = None
|
||||
|
||||
if owner and owner != 'weather_sat':
|
||||
logger.debug(
|
||||
'Skipping SDR release for device %s owned by %s',
|
||||
device_index,
|
||||
owner,
|
||||
)
|
||||
return
|
||||
|
||||
app_module.release_sdr_device(device_index)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get weather satellite decoder status.
|
||||
@@ -106,6 +136,13 @@ def start_capture():
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
# Validate satellite
|
||||
satellite = data.get('satellite')
|
||||
@@ -128,18 +165,30 @@ def start_capture():
|
||||
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Claim SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
except ImportError:
|
||||
pass
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Claim SDR device (skip for remote rtl_tcp)
|
||||
if not rtl_tcp_host:
|
||||
try:
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Clear queue
|
||||
while not _weather_sat_queue.empty():
|
||||
@@ -152,19 +201,19 @@ def start_capture():
|
||||
decoder.set_callback(_progress_callback)
|
||||
|
||||
def _release_device():
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
if not rtl_tcp_host:
|
||||
_release_weather_sat_device(device_index)
|
||||
|
||||
decoder.set_on_complete(_release_device)
|
||||
|
||||
success = decoder.start(
|
||||
success, error_msg = decoder.start(
|
||||
satellite=satellite,
|
||||
device_index=device_index,
|
||||
gain=gain,
|
||||
sample_rate=DEFAULT_SAMPLE_RATE,
|
||||
bias_t=bias_t,
|
||||
rtl_tcp_host=rtl_tcp_host,
|
||||
rtl_tcp_port=rtl_tcp_port,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -181,7 +230,7 @@ def start_capture():
|
||||
_release_device()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start capture'
|
||||
'message': error_msg or 'Failed to start capture'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -283,7 +332,7 @@ def test_decode():
|
||||
decoder.set_callback(_progress_callback)
|
||||
decoder.set_on_complete(None)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
success, error_msg = decoder.start_from_file(
|
||||
satellite=satellite,
|
||||
input_file=input_file,
|
||||
sample_rate=sample_rate,
|
||||
@@ -302,7 +351,7 @@ def test_decode():
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start file decode'
|
||||
'message': error_msg or 'Failed to start file decode'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -318,12 +367,7 @@ def stop_capture():
|
||||
|
||||
decoder.stop()
|
||||
|
||||
# Release SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
_release_weather_sat_device(device_index)
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -566,14 +610,21 @@ def enable_schedule():
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler'
|
||||
}), 500
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
|
||||
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
"""WeFax (Weather Fax) decoder routes.
|
||||
|
||||
Provides endpoints for decoding HF weather fax transmissions from
|
||||
maritime/aviation weather services worldwide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sdr import SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_frequency
|
||||
from utils.wefax import get_wefax_decoder
|
||||
from utils.wefax_stations import (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ,
|
||||
get_current_broadcasts,
|
||||
get_station,
|
||||
load_stations,
|
||||
resolve_tuning_frequency_khz,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.wefax')
|
||||
|
||||
wefax_bp = Blueprint('wefax', __name__, url_prefix='/wefax')
|
||||
|
||||
# SSE progress queue
|
||||
_wefax_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track active SDR device
|
||||
wefax_active_device: int | None = None
|
||||
wefax_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
|
||||
try:
|
||||
_wefax_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
_wefax_queue.put_nowait(data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Ensure manually claimed SDR devices are always released when a
|
||||
# decode session ends on its own (complete/error/stopped).
|
||||
if (
|
||||
isinstance(data, dict)
|
||||
and data.get('type') == 'wefax_progress'
|
||||
and data.get('status') in ('complete', 'error', 'stopped')
|
||||
and wefax_active_device is not None
|
||||
):
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
|
||||
@wefax_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get WeFax decoder status."""
|
||||
decoder = get_wefax_decoder()
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'running': decoder.is_running,
|
||||
'image_count': len(decoder.get_images()),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/start', methods=['POST'])
|
||||
def start_decoder():
|
||||
"""Start WeFax decoder.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"frequency_khz": 4298,
|
||||
"station": "NOJ",
|
||||
"device": 0,
|
||||
"gain": 40,
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"direct_sampling": true,
|
||||
"frequency_reference": "auto" // auto, carrier, or dial
|
||||
}
|
||||
"""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'message': 'WeFax decoder is already running',
|
||||
})
|
||||
|
||||
# Clear queue
|
||||
while not _wefax_queue.empty():
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate frequency (required)
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency_khz is required',
|
||||
}), 400
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
# WeFax operates on HF: 2-30 MHz (2000-30000 kHz)
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency: {e}',
|
||||
}), 400
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
device_index = data.get('device', 0)
|
||||
gain = float(data.get('gain', 40.0))
|
||||
ioc = int(data.get('ioc', 576))
|
||||
lpm = int(data.get('lpm', 120))
|
||||
direct_sampling = bool(data.get('direct_sampling', True))
|
||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||
|
||||
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 not frequency_reference:
|
||||
frequency_reference = 'auto'
|
||||
|
||||
try:
|
||||
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=frequency_khz,
|
||||
station_callsign=station,
|
||||
frequency_reference=frequency_reference,
|
||||
)
|
||||
)
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency settings: {e}',
|
||||
}), 400
|
||||
|
||||
# Validate IOC and LPM
|
||||
if ioc not in (288, 576):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'IOC must be 288 or 576',
|
||||
}), 400
|
||||
|
||||
if lpm not in (60, 120):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'LPM must be 60 or 120',
|
||||
}), 400
|
||||
|
||||
# Claim SDR device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
success = decoder.start(
|
||||
frequency_khz=tuned_frequency_khz,
|
||||
station=station,
|
||||
device_index=device_int,
|
||||
gain=gain,
|
||||
ioc=ioc,
|
||||
lpm=lpm,
|
||||
direct_sampling=direct_sampling,
|
||||
sdr_type=sdr_type_str,
|
||||
)
|
||||
|
||||
if success:
|
||||
wefax_active_device = device_int
|
||||
wefax_active_sdr_type = sdr_type_str
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency_khz': frequency_khz,
|
||||
'tuned_frequency_khz': tuned_frequency_khz,
|
||||
'frequency_reference': resolved_reference,
|
||||
'usb_offset_applied': usb_offset_applied,
|
||||
'usb_offset_khz': (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
|
||||
),
|
||||
'station': station,
|
||||
'ioc': ioc,
|
||||
'lpm': lpm,
|
||||
'device': device_int,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
}), 500
|
||||
|
||||
|
||||
@wefax_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop WeFax decoder."""
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
decoder = get_wefax_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if wefax_active_device is not None:
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wefax_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of WeFax decode progress."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_wefax_queue,
|
||||
channel_key='wefax',
|
||||
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
|
||||
|
||||
|
||||
@wefax_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded WeFax images."""
|
||||
decoder = get_wefax_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Get a decoded WeFax image file."""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
|
||||
@wefax_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded WeFax image."""
|
||||
decoder = get_wefax_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@wefax_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""Delete all decoded WeFax images."""
|
||||
decoder = get_wefax_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
# ========================
|
||||
# Auto-Scheduler Endpoints
|
||||
# ========================
|
||||
|
||||
|
||||
def _scheduler_event_callback(event: dict) -> None:
|
||||
"""Forward scheduler events to the SSE queue."""
|
||||
try:
|
||||
_wefax_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_wefax_queue.get_nowait()
|
||||
_wefax_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/enable', methods=['POST'])
|
||||
def enable_schedule():
|
||||
"""Enable auto-scheduling of WeFax broadcast captures.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"station": "NOJ",
|
||||
"frequency_khz": 4298,
|
||||
"device": 0,
|
||||
"gain": 40,
|
||||
"ioc": 576,
|
||||
"lpm": 120,
|
||||
"direct_sampling": true,
|
||||
"frequency_reference": "auto" // auto, carrier, or dial
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with scheduler status.
|
||||
"""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
station = str(data.get('station', '')).strip()
|
||||
if not station:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'station is required',
|
||||
}), 400
|
||||
|
||||
frequency_khz = data.get('frequency_khz')
|
||||
if frequency_khz is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency_khz is required',
|
||||
}), 400
|
||||
|
||||
try:
|
||||
frequency_khz = float(frequency_khz)
|
||||
freq_mhz = frequency_khz / 1000.0
|
||||
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency: {e}',
|
||||
}), 400
|
||||
|
||||
device = int(data.get('device', 0))
|
||||
gain = float(data.get('gain', 40.0))
|
||||
ioc = int(data.get('ioc', 576))
|
||||
lpm = int(data.get('lpm', 120))
|
||||
direct_sampling = bool(data.get('direct_sampling', True))
|
||||
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
|
||||
if not frequency_reference:
|
||||
frequency_reference = 'auto'
|
||||
|
||||
try:
|
||||
tuned_frequency_khz, resolved_reference, usb_offset_applied = (
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=frequency_khz,
|
||||
station_callsign=station,
|
||||
frequency_reference=frequency_reference,
|
||||
)
|
||||
)
|
||||
tuned_mhz = tuned_frequency_khz / 1000.0
|
||||
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid frequency settings: {e}',
|
||||
}), 400
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
station=station,
|
||||
frequency_khz=tuned_frequency_khz,
|
||||
device=device,
|
||||
gain=gain,
|
||||
ioc=ioc,
|
||||
lpm=lpm,
|
||||
direct_sampling=direct_sampling,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to enable WeFax scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler',
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
**result,
|
||||
'frequency_khz': frequency_khz,
|
||||
'tuned_frequency_khz': tuned_frequency_khz,
|
||||
'frequency_reference': resolved_reference,
|
||||
'usb_offset_applied': usb_offset_applied,
|
||||
'usb_offset_khz': (
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/disable', methods=['POST'])
|
||||
def disable_schedule():
|
||||
"""Disable auto-scheduling."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
result = scheduler.disable()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/status')
|
||||
def schedule_status():
|
||||
"""Get current scheduler state."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
return jsonify(scheduler.get_status())
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/broadcasts')
|
||||
def schedule_broadcasts():
|
||||
"""List scheduled broadcasts."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
broadcasts = scheduler.get_broadcasts()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'broadcasts': broadcasts,
|
||||
'count': len(broadcasts),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/schedule/skip/<broadcast_id>', methods=['POST'])
|
||||
def skip_broadcast(broadcast_id: str):
|
||||
"""Skip a scheduled broadcast."""
|
||||
from utils.wefax_scheduler import get_wefax_scheduler
|
||||
|
||||
if not broadcast_id.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid broadcast ID',
|
||||
}), 400
|
||||
|
||||
scheduler = get_wefax_scheduler()
|
||||
if scheduler.skip_broadcast(broadcast_id):
|
||||
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Broadcast not found or already processed',
|
||||
}), 404
|
||||
|
||||
|
||||
@wefax_bp.route('/stations')
|
||||
def list_stations():
|
||||
"""Get all WeFax stations from the database."""
|
||||
stations = load_stations()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'stations': stations,
|
||||
'count': len(stations),
|
||||
})
|
||||
|
||||
|
||||
@wefax_bp.route('/stations/<callsign>')
|
||||
def station_detail(callsign: str):
|
||||
"""Get station detail including current schedule info."""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Station {callsign} not found',
|
||||
}), 404
|
||||
|
||||
current = get_current_broadcasts(callsign)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'station': station,
|
||||
'current_broadcasts': current,
|
||||
})
|
||||
+35
-50
@@ -20,7 +20,7 @@ from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import format_sse, sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
@@ -1132,33 +1132,26 @@ def get_wifi_networks():
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.wifi_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.wifi_queue,
|
||||
channel_key='wifi',
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1545,8 +1538,8 @@ def v2_deauth_status():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
"""
|
||||
SSE stream for real-time deauth alerts.
|
||||
|
||||
@@ -1557,26 +1550,18 @@ def v2_deauth_stream():
|
||||
- deauth_error: An error occurred
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Try to get from the dedicated deauth queue
|
||||
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
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['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.deauth_detector_queue,
|
||||
channel_key='wifi_deauth',
|
||||
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'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
# INTERCEPT - Production Startup Script
|
||||
#
|
||||
# Starts INTERCEPT with gunicorn + gevent for production use.
|
||||
# Falls back to Flask dev server if gunicorn is not installed.
|
||||
#
|
||||
# Requires sudo for SDR, WiFi monitor mode, and Bluetooth access.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./start.sh # Default: 0.0.0.0:5050
|
||||
# sudo ./start.sh -p 8080 # Custom port
|
||||
# sudo ./start.sh --https # HTTPS with self-signed cert
|
||||
# sudo ./start.sh --debug # Debug mode (Flask dev server)
|
||||
# sudo ./start.sh --check-deps # Check dependencies and exit
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Resolve Python from venv or system ───────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ── Load .env if present ──────────────────────────────────────────────────────
|
||||
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||
set -a
|
||||
source "$SCRIPT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPT_DIR/venv/bin/python" ]]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
elif [[ -n "${VIRTUAL_ENV:-}" && -x "$VIRTUAL_ENV/bin/python" ]]; then
|
||||
PYTHON="$VIRTUAL_ENV/bin/python"
|
||||
else
|
||||
PYTHON="$(command -v python3 || command -v python)"
|
||||
fi
|
||||
|
||||
# ── Defaults (can be overridden by env vars or CLI flags) ────────────────────
|
||||
HOST="${INTERCEPT_HOST:-0.0.0.0}"
|
||||
PORT="${INTERCEPT_PORT:-5050}"
|
||||
DEBUG=0
|
||||
HTTPS=0
|
||||
CHECK_DEPS=0
|
||||
|
||||
# ── Parse CLI arguments ─────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-p|--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-H|--host)
|
||||
HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--debug)
|
||||
DEBUG=1
|
||||
shift
|
||||
;;
|
||||
--https)
|
||||
HTTPS=1
|
||||
shift
|
||||
;;
|
||||
--check-deps)
|
||||
CHECK_DEPS=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: start.sh [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -p, --port PORT Port to listen on (default: 5050)"
|
||||
echo " -H, --host HOST Host to bind to (default: 0.0.0.0)"
|
||||
echo " -d, --debug Run in debug mode (Flask dev server)"
|
||||
echo " --https Enable HTTPS with self-signed certificate"
|
||||
echo " --check-deps Check dependencies and exit"
|
||||
echo " -h, --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Export for config.py ─────────────────────────────────────────────────────
|
||||
export INTERCEPT_HOST="$HOST"
|
||||
export INTERCEPT_PORT="$PORT"
|
||||
|
||||
# ── Fix ownership of user data dirs when run via sudo ────────────────────────
|
||||
# When invoked via sudo the server process runs as root, so every file it
|
||||
# creates (configs, logs, database) ends up owned by root. On the *next*
|
||||
# startup we fix that retroactively, and we also pre-create known runtime
|
||||
# directories so they get correct ownership from the start.
|
||||
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
|
||||
# Pre-create directories that routes may need at runtime
|
||||
mkdir -p "$SCRIPT_DIR/instance" \
|
||||
"$SCRIPT_DIR/data/radiosonde/logs" \
|
||||
"$SCRIPT_DIR/data/weather_sat"
|
||||
|
||||
for dir in instance data certs; do
|
||||
if [[ -d "$SCRIPT_DIR/$dir" ]]; then
|
||||
chown -R "$SUDO_USER" "$SCRIPT_DIR/$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
# Export real user identity so Python can chown runtime-created files
|
||||
export INTERCEPT_SUDO_UID="$(id -u "$SUDO_USER")"
|
||||
export INTERCEPT_SUDO_GID="$(id -g "$SUDO_USER")"
|
||||
fi
|
||||
|
||||
# ── Dependency check (delegate to intercept.py) ─────────────────────────────
|
||||
if [[ "$CHECK_DEPS" -eq 1 ]]; then
|
||||
exec "$PYTHON" intercept.py --check-deps
|
||||
fi
|
||||
|
||||
# ── Debug mode always uses Flask dev server ──────────────────────────────────
|
||||
if [[ "$DEBUG" -eq 1 ]]; then
|
||||
echo "[INTERCEPT] Starting in debug mode (Flask dev server)..."
|
||||
export INTERCEPT_DEBUG=1
|
||||
exec "$PYTHON" intercept.py --host "$HOST" --port "$PORT" --debug
|
||||
fi
|
||||
|
||||
# ── HTTPS certificate generation ────────────────────────────────────────────
|
||||
CERT_DIR="certs"
|
||||
CERT_FILE="$CERT_DIR/intercept.crt"
|
||||
KEY_FILE="$CERT_DIR/intercept.key"
|
||||
|
||||
if [[ "$HTTPS" -eq 1 ]]; then
|
||||
if [[ ! -f "$CERT_FILE" || ! -f "$KEY_FILE" ]]; then
|
||||
echo "[INTERCEPT] Generating self-signed SSL certificate..."
|
||||
mkdir -p "$CERT_DIR"
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$KEY_FILE" -out "$CERT_FILE" \
|
||||
-days 365 -nodes \
|
||||
-subj '/CN=intercept/O=INTERCEPT/C=US' 2>/dev/null
|
||||
echo "[INTERCEPT] SSL certificate generated: $CERT_FILE"
|
||||
else
|
||||
echo "[INTERCEPT] Using existing SSL certificate: $CERT_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Detect gunicorn + gevent ─────────────────────────────────────────────────
|
||||
HAS_GUNICORN=0
|
||||
HAS_GEVENT=0
|
||||
|
||||
if "$PYTHON" -c "import gunicorn" 2>/dev/null; then
|
||||
HAS_GUNICORN=1
|
||||
fi
|
||||
if "$PYTHON" -c "import gevent" 2>/dev/null; then
|
||||
HAS_GEVENT=1
|
||||
fi
|
||||
|
||||
# ── Resolve LAN address for display ──────────────────────────────────────────
|
||||
if [[ "$HOST" == "0.0.0.0" ]]; then
|
||||
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
LAN_IP="${LAN_IP:-localhost}"
|
||||
else
|
||||
LAN_IP="$HOST"
|
||||
fi
|
||||
PROTO="http"
|
||||
[[ "$HTTPS" -eq 1 ]] && PROTO="https"
|
||||
|
||||
# ── Start the server ─────────────────────────────────────────────────────────
|
||||
if [[ "$HAS_GUNICORN" -eq 1 && "$HAS_GEVENT" -eq 1 ]]; then
|
||||
echo "[INTERCEPT] Starting production server (gunicorn + gevent)..."
|
||||
echo "[INTERCEPT] Listening on ${PROTO}://${LAN_IP}:${PORT}"
|
||||
|
||||
GUNICORN_ARGS=(
|
||||
-c "$SCRIPT_DIR/gunicorn.conf.py"
|
||||
-k gevent
|
||||
-w 1
|
||||
--timeout 300
|
||||
--graceful-timeout 5
|
||||
--worker-connections 1000
|
||||
--bind "${HOST}:${PORT}"
|
||||
--access-logfile -
|
||||
--error-logfile -
|
||||
)
|
||||
|
||||
if [[ "$HTTPS" -eq 1 ]]; then
|
||||
GUNICORN_ARGS+=(--certfile "$CERT_FILE" --keyfile "$KEY_FILE")
|
||||
echo "[INTERCEPT] HTTPS enabled"
|
||||
fi
|
||||
|
||||
exec "$PYTHON" -m gunicorn "${GUNICORN_ARGS[@]}" app:app
|
||||
else
|
||||
if [[ "$HAS_GUNICORN" -eq 0 ]]; then
|
||||
echo "[INTERCEPT] gunicorn not found — falling back to Flask dev server"
|
||||
fi
|
||||
if [[ "$HAS_GEVENT" -eq 0 ]]; then
|
||||
echo "[INTERCEPT] gevent not found — falling back to Flask dev server"
|
||||
fi
|
||||
echo "[INTERCEPT] Install with: pip install gunicorn gevent"
|
||||
echo ""
|
||||
|
||||
FLASK_ARGS=(--host "$HOST" --port "$PORT")
|
||||
if [[ "$HTTPS" -eq 1 ]]; then
|
||||
FLASK_ARGS+=(--https)
|
||||
fi
|
||||
|
||||
exec "$PYTHON" intercept.py "${FLASK_ARGS[@]}"
|
||||
fi
|
||||
@@ -27,6 +27,27 @@
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-dark: #f4f7fb;
|
||||
--bg-panel: #e9eef5;
|
||||
--bg-card: #ffffff;
|
||||
--border-color: #d1d9e6;
|
||||
--border-glow: #1f5fa8;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-orange: #b5863a;
|
||||
--accent-red: #c74444;
|
||||
--accent-yellow: #b5863a;
|
||||
--accent-amber: #b5863a;
|
||||
--grid-line: rgba(31, 95, 168, 0.12);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #1f5fa8;
|
||||
--radar-bg: #e9eef5;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
@@ -393,7 +414,7 @@ body {
|
||||
.acars-sidebar .acars-btn {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -554,7 +575,7 @@ body {
|
||||
.vdl2-sidebar .vdl2-btn {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -872,6 +893,92 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-crosshair-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 1200;
|
||||
--crosshair-x-start: 100%;
|
||||
--crosshair-y-start: 100%;
|
||||
--crosshair-x-end: 50%;
|
||||
--crosshair-y-end: 50%;
|
||||
--crosshair-duration: 1500ms;
|
||||
}
|
||||
|
||||
.map-crosshair-line {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.map-crosshair-vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
left: 0;
|
||||
transform: translateX(var(--crosshair-x-start));
|
||||
}
|
||||
|
||||
.map-crosshair-horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
top: 0;
|
||||
transform: translateY(var(--crosshair-y-start));
|
||||
}
|
||||
|
||||
.map-crosshair-overlay.active .map-crosshair-vertical {
|
||||
animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||
animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes mapCrosshairSweepX {
|
||||
0% {
|
||||
transform: translateX(var(--crosshair-x-start));
|
||||
opacity: 0;
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(var(--crosshair-x-end));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mapCrosshairSweepY {
|
||||
0% {
|
||||
transform: translateY(var(--crosshair-y-start));
|
||||
opacity: 0;
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(var(--crosshair-y-end));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.map-crosshair-overlay.active .map-crosshair-vertical,
|
||||
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||
animation-duration: 220ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right sidebar - Mobile first */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
@@ -1139,7 +1246,7 @@ body {
|
||||
|
||||
.control-group select {
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
@@ -1240,7 +1347,7 @@ body {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -1258,7 +1365,7 @@ body {
|
||||
|
||||
.start-btn.active {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.start-btn.active:hover {
|
||||
@@ -1390,7 +1497,7 @@ body {
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
@@ -1411,7 +1518,7 @@ body {
|
||||
|
||||
.airband-btn.active {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.airband-btn.active:hover {
|
||||
@@ -1805,7 +1912,7 @@ body {
|
||||
.strip-report-btn {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
@@ -2117,7 +2224,7 @@ body {
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.strip-btn.primary:hover {
|
||||
|
||||
@@ -269,6 +269,21 @@ body {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.data-control-group {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.data-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-actions input[type="date"] {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent-cyan);
|
||||
border: none;
|
||||
@@ -285,6 +300,31 @@ body {
|
||||
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.primary-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.warn-btn {
|
||||
background: var(--accent-amber);
|
||||
color: #0a0c10;
|
||||
}
|
||||
|
||||
.warn-btn:hover {
|
||||
box-shadow: 0 6px 14px rgba(214, 168, 94, 0.3);
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: #d84f63;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
box-shadow: 0 6px 14px rgba(216, 79, 99, 0.35);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
@@ -296,6 +336,16 @@ body {
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.status-pill.error {
|
||||
border-color: #d84f63;
|
||||
color: #d84f63;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||
@@ -364,6 +414,37 @@ body {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.aircraft-row.military {
|
||||
background: rgba(85, 107, 47, 0.12);
|
||||
}
|
||||
|
||||
.aircraft-row.military:hover {
|
||||
background: rgba(85, 107, 47, 0.22);
|
||||
}
|
||||
|
||||
.mil-badge,
|
||||
.civ-badge {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mil-badge {
|
||||
background: rgba(85, 107, 47, 0.35);
|
||||
color: #a3b86c;
|
||||
border: 1px solid rgba(85, 107, 47, 0.6);
|
||||
}
|
||||
|
||||
.civ-badge {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid rgba(74, 158, 255, 0.25);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -614,6 +695,15 @@ body {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.data-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-actions input[type="date"],
|
||||
.data-actions .primary-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,27 @@
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-dark: #f4f7fb;
|
||||
--bg-panel: #e9eef5;
|
||||
--bg-card: #ffffff;
|
||||
--border-color: #d1d9e6;
|
||||
--border-glow: #1f5fa8;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-orange: #b5863a;
|
||||
--accent-red: #c74444;
|
||||
--accent-yellow: #b5863a;
|
||||
--accent-amber: #b5863a;
|
||||
--grid-line: rgba(31, 95, 168, 0.12);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #1f5fa8;
|
||||
--radar-bg: #e9eef5;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
@@ -373,7 +394,7 @@ body {
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Main dashboard grid - Mobile first */
|
||||
@@ -758,7 +779,7 @@ body {
|
||||
|
||||
.control-group select {
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
@@ -791,7 +812,7 @@ body {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -809,7 +830,7 @@ body {
|
||||
|
||||
.start-btn.active {
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.start-btn.active:hover {
|
||||
@@ -1261,7 +1282,7 @@ body {
|
||||
.dsc-distress-alert button {
|
||||
background: var(--accent-red);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
padding: 10px 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
@@ -1292,7 +1313,7 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
.activity-timeline-btn.active {
|
||||
background: var(--timeline-accent);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--timeline-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
.function-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
@@ -195,7 +195,7 @@
|
||||
.function-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
@@ -304,7 +304,7 @@
|
||||
border-color: rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
.function-strip.bt-strip .strip-value {
|
||||
color: #0a84ff;
|
||||
color: var(--accent-blue, #0a84ff);
|
||||
}
|
||||
|
||||
.function-strip.wifi-strip .strip-stat {
|
||||
@@ -332,24 +332,24 @@
|
||||
border-color: rgba(255, 59, 48, 0.6);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-value {
|
||||
color: #ef4444; /* Explicit red color */
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-label {
|
||||
color: #9ca3af; /* Explicit light gray */
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-select {
|
||||
color: #e8eaed; /* Explicit white for selects */
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-btn {
|
||||
color: #e8eaed; /* Explicit white for buttons */
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-tool {
|
||||
color: #e8eaed; /* Explicit white for tool indicators */
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-time,
|
||||
.function-strip.tscm-strip .strip-status span {
|
||||
color: #9ca3af; /* Explicit gray for status/time */
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.function-strip.rtlamr-strip .strip-stat {
|
||||
@@ -361,7 +361,7 @@
|
||||
border-color: rgba(175, 82, 222, 0.3);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-value {
|
||||
color: #af52de;
|
||||
color: var(--accent-purple, #af52de);
|
||||
}
|
||||
|
||||
.function-strip.listening-strip .strip-stat {
|
||||
|
||||
@@ -1,295 +1,293 @@
|
||||
/**
|
||||
* Proximity Visualization Components
|
||||
* Styles for radar and timeline heatmap
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY RADAR
|
||||
============================================ */
|
||||
|
||||
.proximity-radar-svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
transform-origin: center center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
.radar-device-hitarea {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
animation: radar-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover {
|
||||
background: var(--bg-hover, #333) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active {
|
||||
background: #00d4ff !important;
|
||||
color: #000 !important;
|
||||
border-color: #00d4ff !important;
|
||||
}
|
||||
|
||||
#btRadarPauseBtn.active {
|
||||
background: #f97316 !important;
|
||||
color: #000 !important;
|
||||
border-color: #f97316 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE HEATMAP
|
||||
============================================ */
|
||||
|
||||
.timeline-heatmap-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
border-color: var(--accent-color, #00d4ff);
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 10px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-btn:hover {
|
||||
background: var(--bg-hover, #252525);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.heatmap-btn.active {
|
||||
background: #f97316;
|
||||
color: #000;
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.timeline-heatmap-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-loading,
|
||||
.heatmap-empty,
|
||||
.heatmap-error {
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heatmap-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-row:hover:not(.heatmap-header) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.heatmap-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-label .device-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.heatmap-label .device-rssi {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 2px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZONE SUMMARY
|
||||
============================================ */
|
||||
|
||||
#btZoneSummary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#btZoneSummary > div {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-heatmap-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proximity-radar-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btZoneSummary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Proximity Visualization Components
|
||||
* Styles for radar and timeline heatmap
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY RADAR
|
||||
============================================ */
|
||||
|
||||
.proximity-radar-svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover .radar-dot {
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
.radar-device-hitarea {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
animation: radar-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover {
|
||||
background: var(--bg-hover, #333) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active {
|
||||
background: var(--accent-cyan) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border-color: var(--accent-cyan) !important;
|
||||
}
|
||||
|
||||
#btRadarPauseBtn.active {
|
||||
background: var(--accent-orange) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border-color: var(--accent-orange) !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE HEATMAP
|
||||
============================================ */
|
||||
|
||||
.timeline-heatmap-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
border-color: var(--accent-color, #00d4ff);
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 10px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-btn:hover {
|
||||
background: var(--bg-hover, #252525);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.heatmap-btn.active {
|
||||
background: var(--accent-orange);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.timeline-heatmap-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-loading,
|
||||
.heatmap-empty,
|
||||
.heatmap-error {
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heatmap-error {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-row:hover:not(.heatmap-header) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.heatmap-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-label .device-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.heatmap-label .device-rssi {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 2px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZONE SUMMARY
|
||||
============================================ */
|
||||
|
||||
#btZoneSummary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#btZoneSummary > div {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-heatmap-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proximity-radar-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btZoneSummary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,19 +279,19 @@
|
||||
|
||||
.signal-proto-badge.aprs {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #06b6d4;
|
||||
color: var(--proto-aprs, #06b6d4);
|
||||
border-color: rgba(6, 182, 212, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.ais {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
color: var(--proto-ais, #8b5cf6);
|
||||
border-color: rgba(139, 92, 246, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.acars {
|
||||
background: rgba(236, 72, 153, 0.15);
|
||||
color: #ec4899;
|
||||
color: var(--proto-acars, #ec4899);
|
||||
border-color: rgba(236, 72, 153, 0.25);
|
||||
}
|
||||
|
||||
@@ -976,25 +976,25 @@
|
||||
/* Meter protocol badges */
|
||||
.signal-proto-badge.meter {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
color: var(--accent-yellow, #eab308);
|
||||
border-color: rgba(234, 179, 8, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.meter.electric {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
color: var(--accent-yellow, #eab308);
|
||||
border-color: rgba(234, 179, 8, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.meter.gas {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
color: #f97316;
|
||||
color: var(--accent-orange, #f97316);
|
||||
border-color: rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.signal-proto-badge.meter.water {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
color: var(--signal-new, #3b82f6);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
@@ -1060,12 +1060,12 @@
|
||||
|
||||
.meter-delta.positive {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.meter-delta.negative {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Sparkline container */
|
||||
@@ -1431,7 +1431,7 @@
|
||||
|
||||
.signal-station-clickable:hover {
|
||||
background: var(--accent-purple);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
|
||||
}
|
||||
@@ -1587,14 +1587,14 @@
|
||||
background: var(--accent-purple, #8a2be2);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.station-raw-copy-btn:hover {
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -1794,14 +1794,14 @@
|
||||
background: var(--accent-purple, #8a2be2);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.signal-details-copy-btn:hover {
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Signal Details Content Sections */
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
.signal-timeline-btn.active {
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Signal Waveform Component
|
||||
* Animated SVG bar waveform for indicating live signal activity
|
||||
*/
|
||||
|
||||
.signal-waveform {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.signal-waveform-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.signal-waveform-bar {
|
||||
will-change: height, y;
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
|
||||
/* Idle breathing animation */
|
||||
.signal-waveform.idle .signal-waveform-bar {
|
||||
animation: signal-waveform-breathe 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.signal-waveform.idle .signal-waveform-bar:nth-child(even) {
|
||||
animation-delay: -1.25s;
|
||||
}
|
||||
|
||||
@keyframes signal-waveform-breathe {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Active state - disable CSS breathing, JS drives heights */
|
||||
.signal-waveform.active .signal-waveform-bar {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
.update-toast-btn-primary {
|
||||
background: var(--accent-green, #22c55e);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.update-toast-btn-primary:hover {
|
||||
@@ -561,7 +561,7 @@
|
||||
|
||||
.update-modal-btn-primary {
|
||||
background: var(--accent-green, #22c55e);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.update-modal-btn-primary:hover:not(:disabled) {
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
/* Shared UX platform components: run-state strip, command palette, setup assistant, and toasts */
|
||||
|
||||
.run-state-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
margin: 6px 12px 0;
|
||||
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(19, 30, 44, 0.96), rgba(11, 18, 28, 0.97));
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.run-state-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#runStateChips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.run-state-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.run-state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(74, 163, 255, 0.25);
|
||||
background: linear-gradient(180deg, rgba(17, 26, 38, 0.82), rgba(12, 18, 28, 0.84));
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-state-chip .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #667788;
|
||||
box-shadow: 0 0 0 0 rgba(102, 119, 136, 0.45);
|
||||
}
|
||||
|
||||
.run-state-chip.running .dot {
|
||||
background: var(--accent-green, #28c27a);
|
||||
box-shadow: 0 0 0 4px rgba(40, 194, 122, 0.16), 0 0 12px rgba(40, 194, 122, 0.35);
|
||||
}
|
||||
|
||||
.run-state-chip.active {
|
||||
border-color: rgba(74, 163, 255, 0.65);
|
||||
color: var(--text-primary, #e6edf5);
|
||||
box-shadow: inset 0 0 0 1px rgba(74, 163, 255, 0.18);
|
||||
}
|
||||
|
||||
.run-state-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.run-state-value {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
}
|
||||
|
||||
.run-state-btn {
|
||||
background: linear-gradient(180deg, rgba(17, 27, 41, 0.9), rgba(10, 16, 25, 0.92));
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
border: 1px solid rgba(74, 163, 255, 0.45);
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.run-state-btn:hover {
|
||||
background: rgba(74, 163, 255, 0.14);
|
||||
border-color: rgba(74, 163, 255, 0.7);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.command-palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 10vh 18px 0;
|
||||
z-index: 25000;
|
||||
background: rgba(4, 8, 14, 0.65);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.command-palette-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.command-palette {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(16, 26, 39, 0.98), rgba(10, 17, 27, 0.98));
|
||||
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-palette-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
}
|
||||
|
||||
.command-palette-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-size: 14px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.command-palette-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-list {
|
||||
max-height: min(62vh, 520px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.command-palette-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.command-palette-item .meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-palette-item .title {
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command-palette-item .desc {
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.command-palette-item .kbd {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.command-palette-item.active,
|
||||
.command-palette-item:hover,
|
||||
.command-palette-item:focus-visible {
|
||||
background: rgba(74, 163, 255, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-palette-empty {
|
||||
padding: 22px 16px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 26000;
|
||||
background: rgba(4, 8, 14, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.setup-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.setup-modal {
|
||||
width: min(760px, 100%);
|
||||
max-height: 84vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: 12px;
|
||||
background: #101926;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
}
|
||||
|
||||
.setup-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
}
|
||||
|
||||
.setup-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.setup-step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.setup-step-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-step-status {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
}
|
||||
|
||||
.setup-step-status.done {
|
||||
color: var(--accent-green, #28c27a);
|
||||
}
|
||||
|
||||
.setup-step-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.setup-step-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setup-btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
background: var(--bg-tertiary, #121f2d);
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setup-btn.primary {
|
||||
color: var(--text-inverse);
|
||||
background: var(--accent-cyan, #4aa3ff);
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.setup-footer {
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border-color, #1e2d3d);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setup-footer-note {
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.app-toast-stack {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 16px;
|
||||
z-index: 25500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(380px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-left: 3px solid var(--accent-cyan, #4aa3ff);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 24, 35, 0.97);
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35);
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.app-toast.error {
|
||||
border-left-color: var(--accent-red, #e25d5d);
|
||||
}
|
||||
|
||||
.app-toast.warning {
|
||||
border-left-color: var(--accent-orange, #d6a85e);
|
||||
}
|
||||
|
||||
.app-toast-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-toast-msg {
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
}
|
||||
|
||||
.app-toast-actions {
|
||||
margin-top: 7px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-toast-actions button {
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary, #132133);
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
font-size: 10px;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-toast-actions button:hover {
|
||||
border-color: rgba(74, 163, 255, 0.5);
|
||||
color: var(--text-primary, #e6edf5);
|
||||
}
|
||||
|
||||
/* ---- Light theme overrides ---- */
|
||||
[data-theme="light"] .run-state-chip {
|
||||
background: linear-gradient(180deg, rgba(233, 238, 245, 0.9), rgba(225, 232, 242, 0.92));
|
||||
border-color: rgba(31, 95, 168, 0.18);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .run-state-chip.active {
|
||||
border-color: rgba(31, 95, 168, 0.4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .run-state-btn {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(241, 244, 249, 0.97));
|
||||
color: var(--accent-cyan);
|
||||
border-color: rgba(31, 95, 168, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] .run-state-btn:hover {
|
||||
background: rgba(31, 95, 168, 0.08);
|
||||
border-color: rgba(31, 95, 168, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.run-state-strip {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.run-state-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.command-palette-overlay {
|
||||
padding: 8vh 10px 0;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.setup-header,
|
||||
.setup-content,
|
||||
.setup-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.app-toast-stack {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,16 @@ body {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
||||
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||
var(--noise-image),
|
||||
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
|
||||
linear-gradient(180deg, var(--grid-dot), transparent 35%),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
|
||||
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
+600
-585
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,11 @@
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||
--surface-glass: rgba(16, 25, 37, 0.82);
|
||||
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||
--ambient-top-left: rgba(74, 163, 255, 0.14);
|
||||
--ambient-top-right: rgba(56, 193, 128, 0.09);
|
||||
--ambient-bottom: rgba(214, 168, 94, 0.06);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
@@ -56,6 +61,18 @@
|
||||
--status-offline: #6f7f94;
|
||||
--status-info: #4aa3ff;
|
||||
|
||||
/* Severity colors */
|
||||
--severity-critical: #ff3366;
|
||||
--severity-high: #ff9933;
|
||||
--severity-medium: #ffcc00;
|
||||
--severity-low: #00ff88;
|
||||
|
||||
/* Data visualization neon colors */
|
||||
--neon-green: #00ff88;
|
||||
--neon-yellow: #ffcc00;
|
||||
--neon-orange: #ff8800;
|
||||
--neon-red: #ff3366;
|
||||
|
||||
/* Subtle grid/pattern */
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--grid-dot: rgba(255, 255, 255, 0.03);
|
||||
@@ -158,6 +175,11 @@
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #f1f4f9;
|
||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||
--surface-glass: rgba(255, 255, 255, 0.84);
|
||||
--surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%);
|
||||
--ambient-top-left: rgba(31, 95, 168, 0.1);
|
||||
--ambient-top-right: rgba(31, 138, 87, 0.06);
|
||||
--ambient-bottom: rgba(181, 134, 58, 0.05);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
@@ -174,6 +196,27 @@
|
||||
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||
--accent-amber: #b5863a;
|
||||
--accent-amber-dim: rgba(181, 134, 58, 0.12);
|
||||
--accent-yellow: #9a8420;
|
||||
--accent-purple: #6b5ba8;
|
||||
|
||||
/* Status colors - light theme */
|
||||
--status-online: #1f8a57;
|
||||
--status-warning: #b5863a;
|
||||
--status-error: #c74444;
|
||||
--status-offline: #6b7c93;
|
||||
--status-info: #1f5fa8;
|
||||
|
||||
/* Severity colors */
|
||||
--severity-critical: #c74444;
|
||||
--severity-high: #b5863a;
|
||||
--severity-medium: #9a8420;
|
||||
--severity-low: #1f8a57;
|
||||
|
||||
/* Data visualization neon replacements */
|
||||
--neon-green: #1a8a50;
|
||||
--neon-yellow: #9a8420;
|
||||
--neon-orange: #b5863a;
|
||||
--neon-red: #c74444;
|
||||
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
|
||||
@@ -382,6 +382,74 @@
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ---- Light theme overrides ---- */
|
||||
[data-theme="light"] .mode-nav {
|
||||
background: linear-gradient(180deg, rgba(240, 244, 250, 0.97) 0%, rgba(232, 238, 247, 0.95) 100%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.8);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-btn:hover,
|
||||
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
|
||||
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu {
|
||||
background: rgba(248, 250, 253, 0.99);
|
||||
box-shadow: 0 16px 36px rgba(18, 40, 66, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.85);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.95);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn {
|
||||
background: rgba(235, 241, 250, 0.7);
|
||||
border-color: rgba(31, 95, 168, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-action-btn {
|
||||
background: rgba(235, 241, 250, 0.85);
|
||||
border-color: rgba(31, 95, 168, 0.14);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-action-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.95);
|
||||
box-shadow: 0 6px 14px rgba(18, 40, 66, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] a.nav-dashboard-btn,
|
||||
[data-theme="light"] a.nav-dashboard-btn:link,
|
||||
[data-theme="light"] a.nav-dashboard-btn:visited {
|
||||
background: rgba(235, 241, 250, 0.7) !important;
|
||||
border-color: rgba(31, 95, 168, 0.12) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
[data-theme="light"] a.nav-dashboard-btn:hover {
|
||||
background: rgba(220, 230, 244, 0.9) !important;
|
||||
box-shadow: 0 4px 10px rgba(18, 40, 66, 0.1);
|
||||
}
|
||||
|
||||
/* Effects/animations toggle icon states */
|
||||
.nav-tool-btn .icon-effects-off {
|
||||
display: none;
|
||||
|
||||
+892
-33
File diff suppressed because it is too large
Load Diff
@@ -89,3 +89,27 @@
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
|
||||
/* ACARS Standalone Message Feed */
|
||||
.acars-message-feed {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
.acars-message-feed::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.acars-message-feed::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.acars-feed-card {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.acars-feed-card:hover {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Clickable ACARS sidebar messages (linked to tracked aircraft) */
|
||||
.acars-message-item[style*="cursor: pointer"]:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
@@ -132,7 +132,7 @@
|
||||
.aprs-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
@@ -140,7 +140,7 @@
|
||||
.aprs-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
|
||||
@@ -170,6 +170,23 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btl-hud-export-format {
|
||||
min-width: 62px;
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btl-hud-audio-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -249,24 +266,115 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.btl-map-container {
|
||||
flex: 1;
|
||||
min-height: 250px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#btLocateMap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 450;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-heat-label {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btl-map-heat-bar {
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btl-map-heat-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 3px;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-rssi-chart-container {
|
||||
height: 100px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
@@ -426,5 +534,95 @@
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
gap: 3px;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Crosshair sweep animation ───────────────────────────────────── */
|
||||
.btl-crosshair-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 1200;
|
||||
--btl-crosshair-x-start: 100%;
|
||||
--btl-crosshair-y-start: 100%;
|
||||
--btl-crosshair-x-end: 50%;
|
||||
--btl-crosshair-y-end: 50%;
|
||||
--btl-crosshair-duration: 1500ms;
|
||||
}
|
||||
|
||||
.btl-crosshair-line {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
background: var(--accent-cyan);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.btl-crosshair-vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
left: 0;
|
||||
transform: translateX(var(--btl-crosshair-x-start));
|
||||
}
|
||||
|
||||
.btl-crosshair-horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
top: 0;
|
||||
transform: translateY(var(--btl-crosshair-y-start));
|
||||
}
|
||||
|
||||
.btl-crosshair-overlay.active .btl-crosshair-vertical {
|
||||
animation: btlCrosshairSweepX var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||
animation: btlCrosshairSweepY var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes btlCrosshairSweepX {
|
||||
0% { transform: translateX(var(--btl-crosshair-x-start)); opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { transform: translateX(var(--btl-crosshair-x-end)); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes btlCrosshairSweepY {
|
||||
0% { transform: translateY(var(--btl-crosshair-y-start)); opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { transform: translateY(var(--btl-crosshair-y-end)); opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btl-crosshair-overlay.active .btl-crosshair-vertical,
|
||||
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||
animation-duration: 220ms;
|
||||
}
|
||||
}
|
||||
|
||||
+110
-10
@@ -139,16 +139,116 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.gps-skyview-canvas-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: min(100%, 430px);
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gps-sky-globe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.gps-sky-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-globe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback #gpsSkyCanvas,
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon {
|
||||
--sat-size: 18px;
|
||||
--sat-color: #8ea6bd;
|
||||
width: var(--sat-size);
|
||||
height: var(--sat-size);
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--sat-color);
|
||||
background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.22), rgba(7, 14, 23, 0.82) 72%);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px var(--sat-color);
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon img {
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon.used {
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon.unused {
|
||||
opacity: 0.72;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 6px var(--sat-color);
|
||||
}
|
||||
|
||||
.gps-sky-label {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2px;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gps-sky-label-cardinal {
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat.unused {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.gps-sky-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
/* Position info panel */
|
||||
.gps-position-panel {
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
|
||||
.mesh-strip-btn.disconnect {
|
||||
background: var(--accent-red, #ff3366);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.mesh-strip-btn.disconnect:hover {
|
||||
@@ -478,7 +478,7 @@
|
||||
0 2px 8px rgba(0, 0, 0, 0.6),
|
||||
0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
|
||||
inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
@@ -1088,7 +1088,7 @@
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1231,7 +1231,7 @@
|
||||
background: var(--accent-cyan);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -1413,7 +1413,7 @@
|
||||
.mesh-position-btn:hover,
|
||||
.mesh-telemetry-btn:hover {
|
||||
background: var(--accent-cyan);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
@@ -1435,7 +1435,7 @@
|
||||
|
||||
.mesh-qr-btn:hover {
|
||||
background: var(--accent-cyan);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/* Meteor Scatter Mode Styles */
|
||||
|
||||
.meteor-visuals-container {
|
||||
--ms-border: rgba(92, 255, 170, 0.24);
|
||||
--ms-surface: linear-gradient(180deg, rgba(12, 19, 31, 0.97) 0%, rgba(5, 9, 17, 0.98) 100%);
|
||||
--ms-accent: #6bffb8;
|
||||
--ms-accent-dim: rgba(107, 255, 184, 0.13);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(circle at 14% -18%, rgba(107, 255, 184, 0.15) 0%, rgba(107, 255, 184, 0) 38%),
|
||||
radial-gradient(circle at 86% -26%, rgba(255, 200, 54, 0.12) 0%, rgba(255, 200, 54, 0) 36%),
|
||||
#03070f;
|
||||
border: 1px solid var(--ms-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 10px 34px rgba(2, 8, 22, 0.55);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Headline Bar ── */
|
||||
|
||||
.ms-headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(8, 14, 25, 0.86);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ms-headline-left,
|
||||
.ms-headline-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ms-headline-tag {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid rgba(107, 255, 184, 0.45);
|
||||
background: var(--ms-accent-dim);
|
||||
color: var(--ms-accent);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ms-headline-tag.idle {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-dim, #667);
|
||||
}
|
||||
|
||||
.ms-headline-tag.detecting {
|
||||
border-color: rgba(255, 215, 0, 0.5);
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
color: #ffd700;
|
||||
animation: ms-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ms-headline-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── Stats Strip ── */
|
||||
|
||||
.ms-stats-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--ms-surface);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.ms-stat-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ms-stat-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #667);
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.ms-stat-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #eee);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ms-stat-value.highlight {
|
||||
color: var(--ms-accent);
|
||||
}
|
||||
|
||||
/* ── Canvas Areas ── */
|
||||
|
||||
.ms-spectrum-wrap {
|
||||
position: relative;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ms-spectrum-wrap canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ms-waterfall-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ms-waterfall-wrap canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Starfield canvas behind the waterfall */
|
||||
.ms-starfield-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ms-timeline-wrap {
|
||||
position: relative;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ms-timeline-wrap canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Events Panel ── */
|
||||
|
||||
.ms-events-panel {
|
||||
flex-shrink: 0;
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(8, 14, 25, 0.9);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ms-events-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ms-events-title {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #667);
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.ms-events-count {
|
||||
font-size: 10px;
|
||||
color: var(--ms-accent);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.ms-events-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ms-events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ms-events-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ms-events-table th {
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
color: var(--text-dim, #667);
|
||||
font-weight: 500;
|
||||
background: rgba(8, 14, 25, 0.95);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.ms-events-table td {
|
||||
padding: 3px 8px;
|
||||
color: var(--text-secondary, #aab);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.ms-events-table tr:hover td {
|
||||
background: rgba(107, 255, 184, 0.04);
|
||||
}
|
||||
|
||||
.ms-events-table .ms-snr-strong {
|
||||
color: var(--ms-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ms-events-table .ms-snr-moderate {
|
||||
color: #ffd782;
|
||||
}
|
||||
|
||||
.ms-events-table .ms-snr-weak {
|
||||
color: var(--text-dim, #667);
|
||||
}
|
||||
|
||||
.ms-tag {
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
margin-right: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-dim, #667);
|
||||
}
|
||||
|
||||
.ms-tag.strong {
|
||||
border-color: rgba(107, 255, 184, 0.3);
|
||||
background: rgba(107, 255, 184, 0.08);
|
||||
color: var(--ms-accent);
|
||||
}
|
||||
|
||||
.ms-tag.moderate {
|
||||
border-color: rgba(255, 215, 130, 0.3);
|
||||
background: rgba(255, 215, 130, 0.08);
|
||||
color: #ffd782;
|
||||
}
|
||||
|
||||
/* ── Ping Highlight Animation (Enhanced) ── */
|
||||
|
||||
@keyframes ms-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes ms-ping-flash {
|
||||
0% {
|
||||
box-shadow: inset 0 0 30px rgba(107, 255, 184, 0.4),
|
||||
0 0 15px rgba(107, 255, 184, 0.2);
|
||||
border-color: rgba(107, 255, 184, 0.6);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 10px rgba(107, 255, 184, 0.15),
|
||||
0 0 5px rgba(107, 255, 184, 0.08);
|
||||
border-color: rgba(107, 255, 184, 0.35);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 rgba(107, 255, 184, 0),
|
||||
0 0 0 rgba(107, 255, 184, 0);
|
||||
border-color: var(--ms-border);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-ping-flash {
|
||||
animation: ms-ping-flash 0.7s ease-out;
|
||||
}
|
||||
|
||||
/* Stats strip glow on detection */
|
||||
@keyframes ms-stats-glow {
|
||||
0% {
|
||||
box-shadow: inset 0 0 20px rgba(107, 255, 184, 0.15);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 rgba(107, 255, 184, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-stats-glow {
|
||||
animation: ms-stats-glow 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Ping counter bounce */
|
||||
@keyframes ms-counter-bounce {
|
||||
0% { transform: scale(1); }
|
||||
30% { transform: scale(1.35); color: #fff; }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.ms-counter-bounce {
|
||||
animation: ms-counter-bounce 0.4s ease-out;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Particle Burst ── */
|
||||
|
||||
@keyframes ms-particle-burst {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--dx, 30px), var(--dy, -30px)) scale(0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: var(--ms-accent);
|
||||
box-shadow: 0 0 4px var(--ms-accent), 0 0 8px rgba(107, 255, 184, 0.3);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
animation: ms-particle-burst 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ── Signal Meter ── */
|
||||
|
||||
.ms-signal-meter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ms-signal-meter-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #667);
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.ms-signal-meter-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.ms-signal-bar {
|
||||
width: 3px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1px;
|
||||
transition: background 0.1s ease, height 0.1s ease;
|
||||
}
|
||||
|
||||
.ms-signal-bar[data-idx="0"] { height: 4px; }
|
||||
.ms-signal-bar[data-idx="1"] { height: 6px; }
|
||||
.ms-signal-bar[data-idx="2"] { height: 7px; }
|
||||
.ms-signal-bar[data-idx="3"] { height: 9px; }
|
||||
.ms-signal-bar[data-idx="4"] { height: 10px; }
|
||||
.ms-signal-bar[data-idx="5"] { height: 12px; }
|
||||
.ms-signal-bar[data-idx="6"] { height: 14px; }
|
||||
.ms-signal-bar[data-idx="7"] { height: 16px; }
|
||||
|
||||
.ms-signal-bar.active {
|
||||
background: var(--ms-accent);
|
||||
box-shadow: 0 0 3px rgba(107, 255, 184, 0.4);
|
||||
}
|
||||
|
||||
.ms-signal-bar.active[data-idx="5"],
|
||||
.ms-signal-bar.active[data-idx="6"],
|
||||
.ms-signal-bar.active[data-idx="7"] {
|
||||
background: #ffd700;
|
||||
box-shadow: 0 0 3px rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
|
||||
.ms-signal-bar.peak {
|
||||
background: rgba(255, 100, 100, 0.7);
|
||||
box-shadow: 0 0 3px rgba(255, 100, 100, 0.3);
|
||||
}
|
||||
|
||||
/* ── Empty State ── */
|
||||
|
||||
.ms-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
color: var(--text-dim, #667);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.ms-empty-state .ms-empty-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.ms-empty-state .ms-empty-text {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
max-width: 280px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ms-stats-strip {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.ms-events-panel {
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ms-stats-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.ms-spectrum-wrap {
|
||||
height: 60px;
|
||||
}
|
||||
.ms-timeline-wrap {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/* Morse Code / CW Decoder Styles */
|
||||
|
||||
.morse-mode-help,
|
||||
.morse-help-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.morse-help-text {
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.morse-hf-note {
|
||||
font-size: 11px;
|
||||
color: #ffaa00;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.morse-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.morse-actions-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.morse-file-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.morse-file-row input[type='file'] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.morse-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.morse-status #morseCharCount {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.morse-ref-grid {
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
max-height: 560px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
line-height: 1.8;
|
||||
columns: 2;
|
||||
column-gap: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.morse-ref-grid.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.morse-ref-toggle {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.morse-ref-divider {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.morse-decoded-panel {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.morse-decoded-panel:empty::before {
|
||||
content: 'Decoded text will appear here...';
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.morse-char {
|
||||
display: inline;
|
||||
animation: morseFadeIn 0.3s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes morseFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.morse-word-space {
|
||||
display: inline;
|
||||
width: 0.5em;
|
||||
}
|
||||
|
||||
.morse-raw-panel {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #1a1a2e;
|
||||
border-radius: 4px;
|
||||
background: #080812;
|
||||
}
|
||||
|
||||
.morse-raw-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #667;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.morse-raw-text {
|
||||
min-height: 30px;
|
||||
max-height: 90px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: #8fd0ff;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.morse-metrics-panel {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: #7a8694;
|
||||
}
|
||||
|
||||
.morse-metrics-panel span {
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1a1a2e;
|
||||
background: #080811;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.morse-status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.morse-status-bar .status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.morse-metrics-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.morse-file-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* OOK Signal Decoder Styles */
|
||||
|
||||
.ook-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ook-preset-add {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ook-preset-add input {
|
||||
width: 80px;
|
||||
background: var(--bg-tertiary, #111);
|
||||
border: 1px solid var(--border-color, #222);
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.ook-preset-hint {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #555);
|
||||
}
|
||||
|
||||
.ook-encoding-btns {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ook-encoding-btns .preset-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ook-timing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ook-timing-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ook-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.ook-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.ook-warning {
|
||||
font-size: 11px;
|
||||
color: #ffaa00;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ook-command-display {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ook-command-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #555);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.ook-command-text {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-deep, #0a0a0a);
|
||||
border: 1px solid var(--border-color, #1a2e1a);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ook-dedup-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ook-dedup-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/* ============================================
|
||||
RADIOSONDE MODE — Scoped Styles
|
||||
============================================ */
|
||||
|
||||
/* Visuals container */
|
||||
.radiosonde-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Map container */
|
||||
#radiosondeMapContainer {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Card container below map */
|
||||
.radiosonde-card-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Individual balloon card */
|
||||
.radiosonde-card {
|
||||
background: var(--bg-card, #1a1e2e);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
flex: 1 1 280px;
|
||||
min-width: 260px;
|
||||
max-width: 400px;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.radiosonde-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background: rgba(0, 204, 255, 0.04);
|
||||
}
|
||||
|
||||
.radiosonde-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.radiosonde-serial {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.radiosonde-type {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Telemetry stat grid */
|
||||
.radiosonde-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.radiosonde-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.radiosonde-stat-value {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.radiosonde-stat-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Leaflet popup overrides for radiosonde */
|
||||
#radiosondeMapContainer .leaflet-popup-content-wrapper {
|
||||
background: var(--bg-card, #1a1e2e);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#radiosondeMapContainer .leaflet-popup-tip {
|
||||
background: var(--bg-card, #1a1e2e);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Scrollbar for card container */
|
||||
.radiosonde-card-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.radiosonde-card-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.radiosonde-card-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Stats strip above map */
|
||||
.radiosonde-strip {
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radiosonde-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
.radiosonde-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 229, 255, 0.05);
|
||||
border: 1px solid rgba(0, 229, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
.radiosonde-strip .strip-stat:hover {
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
.radiosonde-strip .strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.radiosonde-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.radiosonde-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.radiosonde-strip .strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.radiosonde-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.radiosonde-strip .status-dot.tracking {
|
||||
background: var(--accent-green);
|
||||
animation: radiosonde-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes radiosonde-strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
.radiosonde-strip .strip-waveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Responsive: stack cards on narrow screens */
|
||||
@media (max-width: 600px) {
|
||||
.radiosonde-card {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.radiosonde-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
/* Space Weather Mode Styles */
|
||||
|
||||
/* Main container */
|
||||
.sw-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Header metrics strip */
|
||||
.sw-header-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-header-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.sw-header-stat + .sw-header-stat {
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-header-value {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sw-header-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sw-header-value.accent-cyan { color: var(--accent-cyan); }
|
||||
.sw-header-value.accent-green { color: var(--neon-green); }
|
||||
.sw-header-value.accent-yellow { color: var(--neon-yellow); }
|
||||
.sw-header-value.accent-orange { color: var(--neon-orange); }
|
||||
.sw-header-value.accent-red { color: var(--neon-red); }
|
||||
|
||||
/* Refresh controls in strip */
|
||||
.sw-strip-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.sw-refresh-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sw-refresh-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sw-last-update {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
/* NOAA G/S/R Scale cards */
|
||||
.sw-scales-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sw-scale-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sw-scale-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-scale-value {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sw-scale-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Scale severity colors */
|
||||
.sw-scale-0 { color: var(--neon-green); border-color: #00ff8833; }
|
||||
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
|
||||
.sw-scale-2 { color: var(--neon-yellow); border-color: #ffcc0033; }
|
||||
.sw-scale-3 { color: var(--neon-orange); border-color: #ff880033; }
|
||||
.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
|
||||
.sw-scale-5 { color: var(--neon-red); border-color: #ff004433; }
|
||||
|
||||
[data-theme="light"] .sw-scale-1 { color: #5a8a00; }
|
||||
[data-theme="light"] .sw-scale-4 { color: #cc3300; }
|
||||
|
||||
/* HF Band conditions grid */
|
||||
.sw-band-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-band-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-band-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(2, 1fr);
|
||||
gap: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-band-header {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-band-name {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sw-band-cond {
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sw-band-good { color: var(--neon-green); background: #00ff8815; }
|
||||
.sw-band-fair { color: var(--neon-yellow); background: #ffcc0015; }
|
||||
.sw-band-poor { color: var(--neon-red); background: #ff336615; }
|
||||
|
||||
/* 2-column dashboard grid */
|
||||
.sw-dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Chart containers */
|
||||
.sw-chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-chart-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-chart-wrap {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.sw-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Flare probability table */
|
||||
.sw-prob-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-prob-table th {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-prob-table td {
|
||||
padding: 4px 6px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Solar image gallery */
|
||||
.sw-image-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-image-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-image-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sw-image-tab:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sw-image-tab.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-cyan)10;
|
||||
}
|
||||
|
||||
.sw-image-frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-image-frame img {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* D-RAP frequency selector */
|
||||
.sw-drap-freqs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Alerts list */
|
||||
.sw-alerts-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-alert-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-alert-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sw-alert-type {
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sw-alert-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-alert-msg {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Active regions table */
|
||||
.sw-regions-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-regions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-regions-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.sw-regions-table td {
|
||||
padding: 4px 6px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Empty / loading states */
|
||||
.sw-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sw-loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: sw-spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes sw-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Full-width card */
|
||||
.sw-full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sw-dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-scales-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-header-strip {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sw-header-stat {
|
||||
padding: 4px 8px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.sw-header-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
.spy-freq-clickable:hover {
|
||||
background: var(--accent-cyan);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
.sstv-general-strip-btn.stop {
|
||||
background: var(--accent-red, #ff3366);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.sstv-general-strip-btn.stop:hover {
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
.sstv-strip-btn.stop {
|
||||
background: var(--accent-red, #ff3366);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.sstv-strip-btn.stop:hover {
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
.sstv-strip-btn.gps:hover {
|
||||
background: var(--accent-green);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
|
||||
.sstv-strip-btn.update-tle:hover {
|
||||
background: var(--accent-orange);
|
||||
color: #000;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
|
||||
+1139
-1139
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user