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/
|
downloads/
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
# Local data
|
|
||||||
downloads/
|
|
||||||
pgdata/
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -58,6 +54,9 @@ intercept_agent_*.cfg
|
|||||||
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
||||||
data/weather_sat/
|
data/weather_sat/
|
||||||
|
|
||||||
|
# Radiosonde runtime data (station config, logs)
|
||||||
|
data/radiosonde/
|
||||||
|
|
||||||
# SDR capture files (large IQ recordings)
|
# SDR capture files (large IQ recordings)
|
||||||
data/subghz/captures/
|
data/subghz/captures/
|
||||||
|
|
||||||
@@ -65,3 +64,6 @@ data/subghz/captures/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Local utility scripts
|
||||||
|
reset-sdr.*
|
||||||
|
|||||||
+136
@@ -2,6 +2,142 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
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
|
## [2.15.0] - 2026-02-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -25,15 +25,25 @@ docker compose --profile basic up -d --build
|
|||||||
|
|
||||||
### Local Setup (Alternative)
|
### Local Setup (Alternative)
|
||||||
```bash
|
```bash
|
||||||
# Initial setup (installs dependencies and configures SDR tools)
|
# First-time setup (interactive wizard with install profiles)
|
||||||
./setup.sh
|
./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
|
sudo -E venv/bin/python intercept.py
|
||||||
|
|
||||||
# Or activate venv first
|
# Other setup utilities
|
||||||
source venv/bin/activate
|
./setup.sh --health-check # Verify installation
|
||||||
sudo -E python intercept.py
|
./setup.sh --postgres-setup # Set up ADS-B history database
|
||||||
|
./setup.sh --menu # Force interactive menu
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@@ -69,8 +79,10 @@ mypy .
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Entry Points
|
### Entry Points
|
||||||
- `intercept.py` - Main entry point script
|
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
|
||||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
- `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/)
|
### Route Blueprints (routes/)
|
||||||
Each signal type has its own Flask blueprint:
|
Each signal type has its own Flask blueprint:
|
||||||
@@ -121,7 +133,7 @@ Each signal type has its own Flask blueprint:
|
|||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. 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.
|
**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()`
|
- **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
|
### 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)
|
- `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)
|
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||||
- Data persisted via `./data:/app/data` volume mount
|
- 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 \
|
soapysdr-module-airspy \
|
||||||
airspy \
|
airspy \
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
|
||||||
# Utilities
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
@@ -92,9 +91,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
libzmq3-dev \
|
libzmq3-dev \
|
||||||
libpulse-dev \
|
libpulse-dev \
|
||||||
libfftw3-dev \
|
libfftw3-bin \
|
||||||
liblapack-dev \
|
liblapack-dev \
|
||||||
libcodec2-dev \
|
|
||||||
libglib2.0-dev \
|
libglib2.0-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
# Build dump1090
|
# Build dump1090
|
||||||
@@ -191,6 +189,28 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
fi \
|
fi \
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& rm -rf /tmp/SatDump \
|
&& 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)
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
&& 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 \
|
&& go install github.com/bemasher/rtlamr@latest \
|
||||||
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||||
&& rm -rf /usr/local/go /tmp/gopath \
|
&& 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
|
# Cleanup build tools to reduce image size
|
||||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||||
&& apt-get remove -y \
|
&& apt-get remove -y \
|
||||||
@@ -247,7 +246,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpulse-dev \
|
libpulse-dev \
|
||||||
libfftw3-dev \
|
libfftw3-dev \
|
||||||
liblapack-dev \
|
liblapack-dev \
|
||||||
libcodec2-dev \
|
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -258,11 +256,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Strip Windows CRLF from shell scripts (git autocrlf can re-introduce them)
|
||||||
|
RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} +
|
||||||
|
|
||||||
# Create data directory for persistence
|
# Create data directory for persistence
|
||||||
RUN mkdir -p /app/data /app/data/weather_sat
|
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
|
||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
|
EXPOSE 5443
|
||||||
|
|
||||||
# Environment variables with defaults
|
# Environment variables with defaults
|
||||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||||
@@ -275,4 +277,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD curl -sf http://localhost:5050/health || exit 1
|
CMD curl -sf http://localhost:5050/health || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "intercept.py"]
|
CMD ["/bin/bash", "start.sh"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||||
<img src="https://img.shields.io/badge/license-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">
|
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -40,30 +40,101 @@ Support the developer of this open-source project
|
|||||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
- **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)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
- **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
|
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||||
- **Meshtastic** - LoRa mesh network integration
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
|
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh
|
./setup.sh # Interactive menu (first run launches setup wizard)
|
||||||
sudo -E venv/bin/python intercept.py
|
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
|
### Docker
|
||||||
@@ -115,16 +186,40 @@ INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
|||||||
docker compose --profile basic up -d
|
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
|
```bash
|
||||||
# Start with ADS-B history and Postgres
|
|
||||||
docker compose --profile history up -d
|
docker compose --profile history up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Set the following environment variables (for example in a `.env` file):
|
Set the following environment variables (in `.env`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
@@ -135,30 +230,6 @@ INTERCEPT_ADSB_DB_USER=intercept
|
|||||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other ADS-B Settings
|
|
||||||
|
|
||||||
Set these as environment variables for either local installs or Docker:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
|
||||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
|
||||||
|
|
||||||
**Local install example**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
|
||||||
python app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker example (.env)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
INTERCEPT_ADSB_AUTO_START=true
|
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
|
||||||
```
|
|
||||||
|
|
||||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -167,11 +238,22 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
|||||||
|
|
||||||
Then open **/adsb/history** for the reporting dashboard.
|
Then open **/adsb/history** for the reporting dashboard.
|
||||||
|
|
||||||
|
### System Health Check
|
||||||
|
|
||||||
|
Verify your installation is complete and working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh --health-check
|
||||||
|
# Or use menu option 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks installed tools, SDR devices, port availability, permissions, Python venv, `.env` configuration, and PostgreSQL connectivity.
|
||||||
|
|
||||||
### Open the Interface
|
### Open the Interface
|
||||||
|
|
||||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||||
|
|
||||||
The credentials can be 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) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
[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) |
|
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-02-01_ba81b697",
|
"version": "2026-02-22_17194a71",
|
||||||
"downloaded": "2026-02-04T17:06:54.806043Z"
|
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ import subprocess
|
|||||||
|
|
||||||
from typing import Any
|
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 werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
@@ -42,8 +42,12 @@ from utils.constants import (
|
|||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
from flask_limiter import Limiter
|
try:
|
||||||
from flask_limiter.util import get_remote_address
|
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
|
# Track application start time for uptime calculation
|
||||||
import time as _time
|
import time as _time
|
||||||
_app_start_time = _time.time()
|
_app_start_time = _time.time()
|
||||||
@@ -54,11 +58,24 @@ app = Flask(__name__)
|
|||||||
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||||
|
|
||||||
# Set up rate limiting
|
# Set up rate limiting
|
||||||
limiter = Limiter(
|
if _has_limiter:
|
||||||
key_func=get_remote_address, # Identifies the user by their IP
|
limiter = Limiter(
|
||||||
app=app,
|
key_func=get_remote_address,
|
||||||
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
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)
|
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||||
@@ -100,11 +117,24 @@ def add_security_headers(response):
|
|||||||
def inject_offline_settings():
|
def inject_offline_settings():
|
||||||
"""Inject offline settings into all templates."""
|
"""Inject offline settings into all templates."""
|
||||||
from utils.database import get_setting
|
from utils.database import get_setting
|
||||||
|
|
||||||
|
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
||||||
|
# third-party tracker/storage defenses in strict browsers.
|
||||||
|
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
|
||||||
|
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
|
||||||
|
if assets_source not in ('local', 'cdn'):
|
||||||
|
assets_source = 'local'
|
||||||
|
if fonts_source not in ('local', 'cdn'):
|
||||||
|
fonts_source = 'local'
|
||||||
|
# Force local delivery for core dashboard pages.
|
||||||
|
assets_source = 'local'
|
||||||
|
fonts_source = 'local'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'offline_settings': {
|
'offline_settings': {
|
||||||
'enabled': get_setting('offline.enabled', False),
|
'enabled': get_setting('offline.enabled', False),
|
||||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
'assets_source': assets_source,
|
||||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
'fonts_source': fonts_source,
|
||||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||||
}
|
}
|
||||||
@@ -177,12 +207,6 @@ dsc_rtl_process = None
|
|||||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
dsc_lock = threading.Lock()
|
dsc_lock = threading.Lock()
|
||||||
|
|
||||||
# DMR / Digital Voice
|
|
||||||
dmr_process = None
|
|
||||||
dmr_rtl_process = None
|
|
||||||
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
||||||
dmr_lock = threading.Lock()
|
|
||||||
|
|
||||||
# TSCM (Technical Surveillance Countermeasures)
|
# TSCM (Technical Surveillance Countermeasures)
|
||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
@@ -191,6 +215,26 @@ tscm_lock = threading.Lock()
|
|||||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
subghz_lock = threading.Lock()
|
subghz_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Radiosonde weather balloon tracking
|
||||||
|
radiosonde_process = None
|
||||||
|
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
radiosonde_lock = threading.Lock()
|
||||||
|
|
||||||
|
# CW/Morse code decoder
|
||||||
|
morse_process = None
|
||||||
|
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
morse_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Meteor scatter detection
|
||||||
|
meteor_process = None
|
||||||
|
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
meteor_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Generic OOK signal decoder
|
||||||
|
ook_process = None
|
||||||
|
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
ook_lock = threading.Lock()
|
||||||
|
|
||||||
# Deauth Attack Detection
|
# Deauth Attack Detection
|
||||||
deauth_detector = None
|
deauth_detector = None
|
||||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
@@ -245,12 +289,12 @@ cleanup_manager.register(deauth_alerts)
|
|||||||
# SDR DEVICE REGISTRY
|
# SDR DEVICE REGISTRY
|
||||||
# ============================================
|
# ============================================
|
||||||
# Tracks which mode is using which SDR device to prevent conflicts
|
# Tracks which mode is using which SDR device to prevent conflicts
|
||||||
# Key: device_index (int), Value: mode_name (str)
|
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
|
||||||
sdr_device_registry: dict[int, str] = {}
|
sdr_device_registry: dict[str, str] = {}
|
||||||
sdr_device_registry_lock = threading.Lock()
|
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.
|
"""Claim an SDR device for a mode.
|
||||||
|
|
||||||
Checks the in-app registry first, then probes the USB device to
|
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:
|
Args:
|
||||||
device_index: The SDR device index to claim
|
device_index: The SDR device index to claim
|
||||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
|
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Error message if device is in use, None if successfully claimed
|
Error message if device is in use, None if successfully claimed
|
||||||
"""
|
"""
|
||||||
|
key = f"{sdr_type}:{device_index}"
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
if device_index in sdr_device_registry:
|
if key in sdr_device_registry:
|
||||||
in_use_by = sdr_device_registry[device_index]
|
in_use_by = sdr_device_registry[key]
|
||||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
return f'SDR device {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
|
# Probe the USB device to catch external processes holding the handle
|
||||||
try:
|
if sdr_type == 'rtlsdr':
|
||||||
from utils.sdr.detection import probe_rtlsdr_device
|
try:
|
||||||
usb_error = probe_rtlsdr_device(device_index)
|
from utils.sdr.detection import probe_rtlsdr_device
|
||||||
if usb_error:
|
usb_error = probe_rtlsdr_device(device_index)
|
||||||
return usb_error
|
if usb_error:
|
||||||
except Exception:
|
return usb_error
|
||||||
pass # If probe fails, let the caller proceed normally
|
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
|
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.
|
"""Release an SDR device from the registry.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to release
|
device_index: The SDR device index to release
|
||||||
|
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||||
"""
|
"""
|
||||||
|
key = f"{sdr_type}:{device_index}"
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
sdr_device_registry.pop(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.
|
"""Get current SDR device allocations.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping device indices to mode names
|
Dictionary mapping 'sdr_type:device_index' keys to mode names
|
||||||
"""
|
"""
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
return dict(sdr_device_registry)
|
return dict(sdr_device_registry)
|
||||||
@@ -389,6 +438,18 @@ def favicon() -> Response:
|
|||||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/sw.js')
|
||||||
|
def service_worker() -> Response:
|
||||||
|
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
||||||
|
resp.headers['Service-Worker-Allowed'] = '/'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/manifest.json')
|
||||||
|
def pwa_manifest() -> Response:
|
||||||
|
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices')
|
@app.route('/devices')
|
||||||
def get_devices() -> Response:
|
def get_devices() -> Response:
|
||||||
"""Get all detected SDR devices with hardware type info."""
|
"""Get all detected SDR devices with hardware type info."""
|
||||||
@@ -405,8 +466,9 @@ def get_devices_status() -> Response:
|
|||||||
result = []
|
result = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
d = device.to_dict()
|
d = device.to_dict()
|
||||||
d['in_use'] = device.index in registry
|
key = f"{device.sdr_type.value}:{device.index}"
|
||||||
d['used_by'] = registry.get(device.index)
|
d['in_use'] = key in registry
|
||||||
|
d['used_by'] = registry.get(key)
|
||||||
result.append(d)
|
result.append(d)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
@@ -661,20 +723,89 @@ def _get_subghz_active() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_dmr_active() -> bool:
|
def _get_singleton_running(module_path: str, getter_name: str, attr: str) -> bool:
|
||||||
"""Check if Digital Voice decoder has an active process."""
|
"""Safely check if a singleton-based mode is running without creating instances."""
|
||||||
try:
|
try:
|
||||||
from routes import dmr as dmr_module
|
import importlib
|
||||||
proc = dmr_module.dmr_dsd_process
|
mod = importlib.import_module(module_path)
|
||||||
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
getter = getattr(mod, getter_name)
|
||||||
|
instance = getter()
|
||||||
|
if instance is None:
|
||||||
|
return False
|
||||||
|
return bool(getattr(instance, attr, False))
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tscm_active() -> bool:
|
||||||
|
"""Check if a TSCM sweep is running."""
|
||||||
|
try:
|
||||||
|
from routes.tscm import _sweep_running
|
||||||
|
return bool(_sweep_running)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||||
|
"""Return Bluetooth active state and best-effort device count."""
|
||||||
|
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||||
|
scanner_running = False
|
||||||
|
scanner_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
|
if bt_scanner is not None:
|
||||||
|
scanner_running = bool(bt_scanner.is_scanning)
|
||||||
|
scanner_count = int(bt_scanner.device_count)
|
||||||
|
except Exception:
|
||||||
|
scanner_running = False
|
||||||
|
scanner_count = 0
|
||||||
|
|
||||||
|
locate_running = False
|
||||||
|
try:
|
||||||
|
from utils.bt_locate import get_locate_session
|
||||||
|
session = get_locate_session()
|
||||||
|
if session and getattr(session, 'active', False):
|
||||||
|
scanner = getattr(session, '_scanner', None)
|
||||||
|
locate_running = bool(scanner and scanner.is_scanning)
|
||||||
|
except Exception:
|
||||||
|
locate_running = False
|
||||||
|
|
||||||
|
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_wifi_health() -> tuple[bool, int, int]:
|
||||||
|
"""Return WiFi active state and best-effort network/client counts."""
|
||||||
|
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
||||||
|
scanner_running = False
|
||||||
|
scanner_networks = 0
|
||||||
|
scanner_clients = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None:
|
||||||
|
status = wifi_scanner.get_status()
|
||||||
|
scanner_running = bool(status.is_scanning)
|
||||||
|
scanner_networks = int(status.networks_found or 0)
|
||||||
|
scanner_clients = int(status.clients_found or 0)
|
||||||
|
except Exception:
|
||||||
|
scanner_running = False
|
||||||
|
scanner_networks = 0
|
||||||
|
scanner_clients = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
legacy_running or scanner_running,
|
||||||
|
max(len(wifi_networks), scanner_networks),
|
||||||
|
max(len(wifi_clients), scanner_clients),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
import time
|
import time
|
||||||
|
bt_active, bt_device_count = _get_bluetooth_health()
|
||||||
|
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'version': VERSION,
|
'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),
|
'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),
|
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
|
||||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
'wifi': wifi_active,
|
||||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
'bluetooth': bt_active,
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
'dmr': _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(),
|
'subghz': _get_subghz_active(),
|
||||||
|
'rtlamr': rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False),
|
||||||
|
'meshtastic': _get_singleton_running('utils.meshtastic', 'get_meshtastic_client', 'is_running'),
|
||||||
|
'sstv': _get_singleton_running('utils.sstv', 'get_sstv_decoder', 'is_running'),
|
||||||
|
'weathersat': _get_singleton_running('utils.weather_sat', 'get_weather_sat_decoder', 'is_running'),
|
||||||
|
'wefax': _get_singleton_running('utils.wefax', 'get_wefax_decoder', 'is_running'),
|
||||||
|
'sstv_general': _get_singleton_running('utils.sstv', 'get_general_sstv_decoder', 'is_running'),
|
||||||
|
'tscm': _get_tscm_active(),
|
||||||
|
'gps': _get_singleton_running('utils.gps', 'get_gps_reader', 'is_running'),
|
||||||
|
'bt_locate': _get_singleton_running('utils.bt_locate', 'get_locate_session', 'is_active'),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
'vessel_count': len(ais_vessels),
|
'vessel_count': len(ais_vessels),
|
||||||
'wifi_networks_count': len(wifi_networks),
|
'wifi_networks_count': wifi_network_count,
|
||||||
'wifi_clients_count': len(wifi_clients),
|
'wifi_clients_count': wifi_client_count,
|
||||||
'bt_devices_count': len(bt_devices),
|
'bt_devices_count': bt_device_count,
|
||||||
'dsc_messages_count': len(dsc_messages),
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -708,13 +849,13 @@ def health_check() -> Response:
|
|||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global vdl2_process
|
global vdl2_process, morse_process, radiosonde_process, ook_process
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
global dmr_process, dmr_rtl_process
|
|
||||||
|
|
||||||
# Import adsb and ais modules to reset their state
|
# Import modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
from routes import ais as ais_module
|
from routes import ais as ais_module
|
||||||
|
from routes import radiosonde as radiosonde_module
|
||||||
from utils.bluetooth import reset_bluetooth_scanner
|
from utils.bluetooth import reset_bluetooth_scanner
|
||||||
|
|
||||||
killed = []
|
killed = []
|
||||||
@@ -722,9 +863,10 @@ def kill_all() -> Response:
|
|||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
'hcitool', 'bluetoothctl', 'satdump',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep'
|
'hackrf_transfer', 'hackrf_sweep',
|
||||||
|
'auto_rx'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -754,6 +896,11 @@ def kill_all() -> Response:
|
|||||||
ais_process = None
|
ais_process = None
|
||||||
ais_module.ais_running = False
|
ais_module.ais_running = False
|
||||||
|
|
||||||
|
# Reset Radiosonde state
|
||||||
|
with radiosonde_lock:
|
||||||
|
radiosonde_process = None
|
||||||
|
radiosonde_module.radiosonde_running = False
|
||||||
|
|
||||||
# Reset ACARS state
|
# Reset ACARS state
|
||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
acars_process = None
|
||||||
@@ -762,6 +909,21 @@ def kill_all() -> Response:
|
|||||||
with vdl2_lock:
|
with vdl2_lock:
|
||||||
vdl2_process = None
|
vdl2_process = None
|
||||||
|
|
||||||
|
# Reset Morse state
|
||||||
|
with morse_lock:
|
||||||
|
morse_process = None
|
||||||
|
|
||||||
|
# Reset OOK state (full cleanup: parser thread, pipes, SDR release)
|
||||||
|
with ook_lock:
|
||||||
|
try:
|
||||||
|
from routes.ook import cleanup_ook
|
||||||
|
cleanup_ook(emit_status=False)
|
||||||
|
except Exception:
|
||||||
|
if ook_process:
|
||||||
|
safe_terminate(ook_process)
|
||||||
|
unregister_process(ook_process)
|
||||||
|
ook_process = None
|
||||||
|
|
||||||
# Reset APRS state
|
# Reset APRS state
|
||||||
with aprs_lock:
|
with aprs_lock:
|
||||||
aprs_process = None
|
aprs_process = None
|
||||||
@@ -772,11 +934,6 @@ def kill_all() -> Response:
|
|||||||
dsc_process = None
|
dsc_process = None
|
||||||
dsc_rtl_process = None
|
dsc_rtl_process = None
|
||||||
|
|
||||||
# Reset DMR state
|
|
||||||
with dmr_lock:
|
|
||||||
dmr_process = None
|
|
||||||
dmr_rtl_process = None
|
|
||||||
|
|
||||||
# Reset Bluetooth state (legacy)
|
# Reset Bluetooth state (legacy)
|
||||||
with bt_lock:
|
with bt_lock:
|
||||||
if bt_process:
|
if bt_process:
|
||||||
@@ -811,6 +968,139 @@ def kill_all() -> Response:
|
|||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
|
||||||
|
"""Generate a self-signed certificate if one doesn't already exist.
|
||||||
|
|
||||||
|
Returns (cert_path, key_path) tuple.
|
||||||
|
"""
|
||||||
|
cert_path = os.path.join(cert_dir, 'intercept.crt')
|
||||||
|
key_path = os.path.join(cert_dir, 'intercept.key')
|
||||||
|
|
||||||
|
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
print(f"Using existing SSL certificate: {cert_path}")
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
print("Generating self-signed SSL certificate...")
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run([
|
||||||
|
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||||
|
'-keyout', key_path, '-out', cert_path,
|
||||||
|
'-days', '365', '-nodes',
|
||||||
|
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
|
||||||
|
|
||||||
|
print(f"SSL certificate generated: {cert_path}")
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
|
||||||
|
_app_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def _init_app() -> None:
|
||||||
|
"""Initialize blueprints, database, and websockets.
|
||||||
|
|
||||||
|
Safe to call multiple times — subsequent calls are no-ops.
|
||||||
|
Called automatically at module level for gunicorn, and also
|
||||||
|
from main() for the Flask dev server path.
|
||||||
|
|
||||||
|
Heavy/network operations (TLE updates, process cleanup) are
|
||||||
|
deferred to a background thread so the worker can serve
|
||||||
|
requests immediately.
|
||||||
|
"""
|
||||||
|
global _app_initialized
|
||||||
|
if _app_initialized:
|
||||||
|
return
|
||||||
|
_app_initialized = True
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Initialize database for settings storage
|
||||||
|
from utils.database import init_db
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Register blueprints (essential — without these, all routes 404)
|
||||||
|
from routes import register_blueprints
|
||||||
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Initialize WebSocket for audio streaming
|
||||||
|
try:
|
||||||
|
from routes.audio_websocket import init_audio_websocket
|
||||||
|
init_audio_websocket(app)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Initialize KiwiSDR WebSocket audio proxy
|
||||||
|
try:
|
||||||
|
from routes.websdr import init_websdr_audio
|
||||||
|
init_websdr_audio(app)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Initialize WebSocket for waterfall streaming
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import init_waterfall_websocket
|
||||||
|
init_waterfall_websocket(app)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Initialize WebSocket for meteor scatter monitoring
|
||||||
|
try:
|
||||||
|
from routes.meteor_websocket import init_meteor_websocket
|
||||||
|
init_meteor_websocket(app)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Defer heavy/network operations so the worker can serve requests immediately
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def _deferred_init():
|
||||||
|
"""Run heavy initialization after a short delay."""
|
||||||
|
import time
|
||||||
|
time.sleep(1) # Let the worker start serving first
|
||||||
|
|
||||||
|
# Clean up stale processes from previous runs
|
||||||
|
try:
|
||||||
|
cleanup_stale_processes()
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Stale process cleanup failed: {e}")
|
||||||
|
|
||||||
|
# Register and start database cleanup
|
||||||
|
try:
|
||||||
|
from utils.database import (
|
||||||
|
cleanup_old_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:
|
def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -837,6 +1127,12 @@ def main() -> None:
|
|||||||
default=config.DEBUG,
|
default=config.DEBUG,
|
||||||
help='Enable debug mode'
|
help='Enable debug mode'
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--https',
|
||||||
|
action='store_true',
|
||||||
|
default=config.HTTPS,
|
||||||
|
help='Enable HTTPS with self-signed certificate'
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--check-deps',
|
'--check-deps',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
@@ -886,83 +1182,21 @@ def main() -> None:
|
|||||||
print("Running as root - full capabilities enabled")
|
print("Running as root - full capabilities enabled")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Clean up any stale processes from previous runs
|
# Ensure app is initialized (no-op if already done by module-level call)
|
||||||
cleanup_stale_processes()
|
_init_app()
|
||||||
cleanup_stale_dump1090()
|
|
||||||
|
|
||||||
# Initialize database for settings storage
|
# Configure SSL if HTTPS is enabled
|
||||||
from utils.database import init_db
|
ssl_context = None
|
||||||
init_db()
|
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
|
protocol = 'https' if ssl_context else 'http'
|
||||||
from utils.database import (
|
print(f"Open {protocol}://localhost:{args.port} in your browser")
|
||||||
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")
|
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
print()
|
print()
|
||||||
@@ -974,4 +1208,5 @@ def main() -> None:
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
threaded=True,
|
threaded=True,
|
||||||
load_dotenv=False,
|
load_dotenv=False,
|
||||||
|
ssl_context=ssl_context,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,97 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.19.0"
|
VERSION = "2.24.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
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",
|
"version": "2.19.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
@@ -67,7 +154,6 @@ CHANGELOG = [
|
|||||||
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
"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",
|
"SDR device lock-up fix from unreleased device registry on crash",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -75,8 +161,6 @@ CHANGELOG = [
|
|||||||
"version": "2.14.0",
|
"version": "2.14.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
|
|
||||||
"DMR visual synthesizer with event-driven spring-physics bars",
|
|
||||||
"HF SSTV general mode with predefined shortwave frequencies",
|
"HF SSTV general mode with predefined shortwave frequencies",
|
||||||
"WebSDR integration for remote HF/shortwave listening",
|
"WebSDR integration for remote HF/shortwave listening",
|
||||||
"Listening Post signal scanner and audio pipeline improvements",
|
"Listening Post signal scanner and audio pipeline improvements",
|
||||||
@@ -222,6 +306,11 @@ PORT = _get_env_int('PORT', 5050)
|
|||||||
DEBUG = _get_env_bool('DEBUG', False)
|
DEBUG = _get_env_bool('DEBUG', False)
|
||||||
THREADED = _get_env_bool('THREADED', True)
|
THREADED = _get_env_bool('THREADED', True)
|
||||||
|
|
||||||
|
# HTTPS / SSL settings
|
||||||
|
HTTPS = _get_env_bool('HTTPS', False)
|
||||||
|
SSL_CERT = _get_env('SSL_CERT', '')
|
||||||
|
SSL_KEY = _get_env('SSL_KEY', '')
|
||||||
|
|
||||||
# Default RTL-SDR settings
|
# Default RTL-SDR settings
|
||||||
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||||
@@ -268,12 +357,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
|||||||
|
|
||||||
# Weather satellite settings
|
# Weather satellite settings
|
||||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
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_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_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_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)
|
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 transceiver settings (HackRF)
|
||||||
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
|
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
|
||||||
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
|
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_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
|
||||||
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
|
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
|
||||||
|
|
||||||
|
# Radiosonde settings
|
||||||
|
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
|
||||||
|
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
|
||||||
|
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
|
||||||
|
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
|
||||||
|
|
||||||
# Update checking
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
|
|||||||
@@ -26,4 +26,7 @@ TLE_SATELLITES = {
|
|||||||
'METEOR-M2-3': ('METEOR-M2 3',
|
'METEOR-M2-3': ('METEOR-M2 3',
|
||||||
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
||||||
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
||||||
|
'METEOR-M2-4': ('METEOR-M2 4',
|
||||||
|
'1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998',
|
||||||
|
'2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker Compose configuration for easy deployment
|
# Docker Compose configuration for easy deployment
|
||||||
#
|
#
|
||||||
|
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
|
||||||
|
#
|
||||||
# Basic usage (build locally):
|
# Basic usage (build locally):
|
||||||
# docker compose --profile basic up -d --build
|
# docker compose --profile basic up -d --build
|
||||||
#
|
#
|
||||||
@@ -18,6 +20,8 @@ services:
|
|||||||
container_name: intercept
|
container_name: intercept
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
# USB device mapping for all USB devices
|
||||||
@@ -29,9 +33,13 @@ services:
|
|||||||
# Optional: mount logs directory
|
# Optional: mount logs directory
|
||||||
# - ./logs:/app/logs
|
# - ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
# ADS-B history is disabled by default
|
# ADS-B history is disabled by default
|
||||||
# To enable, use: docker compose --profile history up -d
|
# To enable, use: docker compose --profile history up -d
|
||||||
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
@@ -70,6 +78,8 @@ services:
|
|||||||
- adsb_db
|
- adsb_db
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
# USB device mapping for all USB devices
|
||||||
@@ -78,9 +88,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
- INTERCEPT_ADSB_DB_PORT=5432
|
- INTERCEPT_ADSB_DB_PORT=5432
|
||||||
@@ -108,6 +122,7 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- history
|
- history
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- POSTGRES_DB=intercept_adsb
|
- POSTGRES_DB=intercept_adsb
|
||||||
- POSTGRES_USER=intercept
|
- POSTGRES_USER=intercept
|
||||||
- POSTGRES_PASSWORD=intercept
|
- POSTGRES_PASSWORD=intercept
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ The controller is the main Intercept application:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd intercept
|
cd intercept
|
||||||
python app.py
|
./setup.sh # First-time setup (choose install profiles)
|
||||||
# Runs on http://localhost:5050
|
sudo ./start.sh # Production server on http://localhost:5050
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure an Agent
|
### 2. Configure an Agent
|
||||||
|
|||||||
+88
-13
@@ -24,17 +24,6 @@ Complete feature list for all modules.
|
|||||||
- **Wideband spectrum analysis** with real-time visualization
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
- **I/Q capture** - record raw samples for offline analysis
|
- **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)
|
## Spy Stations (Number Stations)
|
||||||
|
|
||||||
- **Comprehensive database** of active number stations and diplomatic networks
|
- **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
|
- **CSV/JSON export** - export captured messages for offline analysis
|
||||||
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
- **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
|
## Listening Post
|
||||||
|
|
||||||
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||||
- **Real-time audio monitoring** with FM and SSB demodulation
|
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||||
- **Cross-module frequency routing** from scanner to decoders
|
- **Cross-module frequency routing** from scanner to decoders
|
||||||
|
- **Waterfall spectrum display** for visual signal identification
|
||||||
- **Customizable frequency presets** and band bookmarks
|
- **Customizable frequency presets** and band bookmarks
|
||||||
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
- **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
|
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||||
- **Multiple meter protocol support** via rtl_tcp integration
|
- **Multiple meter protocol support** via rtl_tcp integration
|
||||||
|
|
||||||
|
## Space Weather
|
||||||
|
|
||||||
|
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||||
|
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||||
|
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||||
|
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||||
|
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||||
|
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||||
|
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||||
|
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||||
|
- **Aurora forecast** - OVATION aurora oval visualization
|
||||||
|
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||||
|
- **Active solar regions** - Current sunspot region data with location and area
|
||||||
|
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||||
|
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||||
|
|
||||||
|
## Radiosonde Weather Balloon Tracking
|
||||||
|
|
||||||
|
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
|
||||||
|
- **Frequency presets** for common radiosonde bands
|
||||||
|
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
|
||||||
|
- **Interactive map** with balloon trajectory and burst point prediction
|
||||||
|
- **Station location** with configurable observer position
|
||||||
|
- **Distance tracking** - real-time distance-to-balloon calculation
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
@@ -242,6 +276,34 @@ Search and rescue Bluetooth device location with GPS-tagged signal trail mapping
|
|||||||
- Bluetooth adapter (built-in or USB)
|
- Bluetooth adapter (built-in or USB)
|
||||||
- GPS receiver (optional, falls back to manual coordinates)
|
- 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
|
## GPS Mode
|
||||||
|
|
||||||
Real-time GPS position tracking with live map visualization.
|
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
|
### Wireless Sweep Features
|
||||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||||
- **RF spectrum analysis** (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
|
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||||
|
|
||||||
@@ -364,6 +426,14 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
|||||||
- **Redundancy** - Multiple nodes for reliable coverage
|
- **Redundancy** - Multiple nodes for reliable coverage
|
||||||
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||||
|
|
||||||
|
## System Health
|
||||||
|
|
||||||
|
- **Telemetry dashboard** with real-time system metrics
|
||||||
|
- **Process monitoring** for all running SDR tools and decoders
|
||||||
|
- **CPU, memory, and disk usage** tracking
|
||||||
|
- **SDR device status** overview
|
||||||
|
- **No SDR required** - monitors system health independently
|
||||||
|
|
||||||
## User Interface
|
## User Interface
|
||||||
|
|
||||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||||
@@ -424,14 +494,19 @@ The settings modal shows availability status for each bundled asset:
|
|||||||
## General
|
## General
|
||||||
|
|
||||||
- **Web-based interface** - no desktop app needed
|
- **Web-based interface** - no desktop app needed
|
||||||
|
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
|
||||||
- **Live message streaming** via Server-Sent Events (SSE)
|
- **Live message streaming** via Server-Sent Events (SSE)
|
||||||
- **Audio alerts** with mute toggle
|
- **Audio alerts** with mute toggle
|
||||||
- **Message export** to CSV/JSON
|
- **Message export** to CSV/JSON
|
||||||
- **Signal activity meter** and waterfall display
|
- **Signal activity meter** and waterfall display
|
||||||
- **Message logging** to file with timestamps
|
- **Message logging** to file with timestamps
|
||||||
- **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
|
- **Automatic device detection** across all supported hardware
|
||||||
- **Hardware-specific validation** - frequency/gain ranges per device type
|
- **Hardware-specific validation** - frequency/gain ranges per device type
|
||||||
|
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
|
||||||
|
- **Native Homebrew detection** for Apple Silicon tool paths
|
||||||
- **Configurable gain and PPM correction**
|
- **Configurable gain and PPM correction**
|
||||||
- **Device intelligence** dashboard with tracking
|
- **Device intelligence** dashboard with tracking
|
||||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||||
|
|||||||
+172
-9
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
|
|||||||
|
|
||||||
## Quick Install
|
## 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
|
```bash
|
||||||
# Install Homebrew if needed
|
# Install Homebrew if needed
|
||||||
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
|
|||||||
brew install hackrf soapyhackrf
|
brew install hackrf soapyhackrf
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debian / Ubuntu / Raspberry Pi OS
|
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Update package lists
|
# 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
|
## Verify Installation
|
||||||
|
|
||||||
### Check dependencies
|
### Check dependencies
|
||||||
@@ -119,11 +271,19 @@ SoapySDRUtil --find
|
|||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This automatically:
|
The setup wizard automatically:
|
||||||
- Detects your OS
|
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
|
||||||
- Creates a virtual environment if needed (for PEP 668 systems)
|
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
|
||||||
- Installs Python dependencies
|
- Creates a virtual environment with system site-packages
|
||||||
- Checks for required tools
|
- 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
|
### Manual setup
|
||||||
```bash
|
```bash
|
||||||
@@ -139,10 +299,13 @@ pip install -r requirements.txt
|
|||||||
After installation:
|
After installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
|
|
||||||
# Custom port
|
# 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.
|
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 -
|
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
|
```bash
|
||||||
export INTERCEPT_HOST=127.0.0.1
|
sudo ./start.sh -H 127.0.0.1
|
||||||
python intercept.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||||
|
|||||||
+17
-7
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
|||||||
# Then create venv with system packages
|
# Then create venv with system packages
|
||||||
python3 -m venv --system-site-packages venv
|
python3 -m venv --system-site-packages venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
sudo venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### "error: externally-managed-environment" (pip blocked)
|
### "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
|
python3.11 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
sudo venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative: Use the setup script
|
### 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
|
```bash
|
||||||
chmod +x setup.sh
|
./setup.sh # Interactive wizard (first run) or menu
|
||||||
./setup.sh
|
./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"
|
### "pip: command not found"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -336,7 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
|||||||
|
|
||||||
Run INTERCEPT with sudo:
|
Run INTERCEPT with sudo:
|
||||||
```bash
|
```bash
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interface not found after enabling monitor mode
|
### 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)
|
### 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)
|
### No aircraft appearing (ADS-B mode)
|
||||||
|
|
||||||
|
|||||||
+14
-5
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
|
|||||||
| `listening` | Listening post |
|
| `listening` | Listening post |
|
||||||
| `spystations` | Spy stations |
|
| `spystations` | Spy stations |
|
||||||
| `meshtastic` | Mesh networking |
|
| `meshtastic` | Mesh networking |
|
||||||
|
| `weathersat` | Weather satellites |
|
||||||
|
| `sstv_general` | HF SSTV |
|
||||||
|
| `gps` | GPS tracking |
|
||||||
|
| `websdr` | WebSDR |
|
||||||
|
| `subghz` | Sub-GHz analyzer |
|
||||||
|
| `bt_locate` | BT Locate |
|
||||||
|
| `wifi_locate` | WiFi Locate |
|
||||||
|
| `analytics` | Analytics dashboard |
|
||||||
|
| `spaceweather` | Space weather |
|
||||||
### Navigation Groups
|
### Navigation Groups
|
||||||
|
|
||||||
The navigation is organized into groups:
|
The navigation is organized into groups:
|
||||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||||
- **Wireless**: WiFi, Bluetooth
|
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||||
- **Security**: TSCM
|
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||||
- **Space**: Satellite, ISS SSTV
|
- **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
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
@@ -239,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
|
|||||||
- Starts SatDump at the correct time and frequency
|
- Starts SatDump at the correct time and frequency
|
||||||
- Decoded images are saved with timestamps
|
- 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
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
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
|
- 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
|
- 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
|
## GPS Mode
|
||||||
|
|
||||||
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
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_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||||
|
|
||||||
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
|
||||||
|
|
||||||
## Command-line Options
|
## Command-line Options
|
||||||
|
|
||||||
|
### Production server (recommended)
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo ./start.sh --help
|
||||||
|
|
||||||
|
-p, --port PORT Port to listen on (default: 5050)
|
||||||
|
-H, --host HOST Host to bind to (default: 0.0.0.0)
|
||||||
|
-d, --debug Run in debug mode (Flask dev server)
|
||||||
|
--https Enable HTTPS with self-signed certificate
|
||||||
|
--check-deps Check dependencies and exit
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
|
||||||
|
|
||||||
|
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
|
||||||
|
|
||||||
|
### Development server
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 intercept.py --help
|
python3 intercept.py --help
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">25+</span>
|
<span class="stat-value">30+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -61,72 +61,78 @@
|
|||||||
|
|
||||||
<div class="carousel-filters">
|
<div class="carousel-filters">
|
||||||
<button class="filter-btn active" data-filter="all">All</button>
|
<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="signals">Signals</button>
|
||||||
<button class="filter-btn" data-filter="aviation">Aviation & Maritime</button>
|
<button class="filter-btn" data-filter="tracking">Tracking</button>
|
||||||
<button class="filter-btn" data-filter="space">Space & Satellite</button>
|
<button class="filter-btn" data-filter="space">Space</button>
|
||||||
<button class="filter-btn" data-filter="wireless">Wireless & Security</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>
|
<button class="filter-btn" data-filter="platform">Platform</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="carousel-wrapper">
|
<div class="carousel-wrapper">
|
||||||
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
||||||
<div class="carousel-track">
|
<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>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
||||||
<h3>Pager Decoding</h3>
|
<h3>Pager Decoding</h3>
|
||||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="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>
|
<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>
|
<h3>433MHz Sensors</h3>
|
||||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" 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>
|
<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>
|
<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>
|
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||||
</div>
|
</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>
|
<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>
|
<h3>Listening Post</h3>
|
||||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||||
</div>
|
</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>
|
<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>
|
<h3>WebSDR</h3>
|
||||||
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||||
</div>
|
</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>
|
<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>
|
<h3>Spy Stations</h3>
|
||||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||||
</div>
|
</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>
|
<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>
|
<h3>APRS</h3>
|
||||||
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||||
</div>
|
</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>
|
<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>
|
<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>
|
||||||
<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>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||||
<h3>Aircraft Tracking</h3>
|
<h3>Aircraft Tracking</h3>
|
||||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="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>
|
<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>
|
<h3>ACARS</h3>
|
||||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||||
</div>
|
</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>
|
<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>
|
<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>
|
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||||
</div>
|
</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>
|
<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>
|
<h3>Vessel Tracking</h3>
|
||||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
<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>
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="space">
|
<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>
|
<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>
|
<h3>GPS Tracking</h3>
|
||||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 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-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>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||||
<h3>WiFi Scanning</h3>
|
<h3>WiFi Scanning</h3>
|
||||||
@@ -172,6 +193,11 @@
|
|||||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="wireless">
|
<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>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||||
<h3>TSCM</h3>
|
<h3>TSCM</h3>
|
||||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||||
@@ -191,6 +217,11 @@
|
|||||||
<h3>Offline Mode</h3>
|
<h3>Offline Mode</h3>
|
||||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-card" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
|
||||||
|
<h3>System Health</h3>
|
||||||
|
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,6 +288,10 @@
|
|||||||
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
||||||
<span class="screenshot-label">WebSDR</span>
|
<span class="screenshot-label">WebSDR</span>
|
||||||
</div>
|
</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">
|
<div class="screenshot-item">
|
||||||
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
||||||
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
||||||
@@ -265,6 +300,14 @@
|
|||||||
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
||||||
<span class="screenshot-label">Weather Satellite</span>
|
<span class="screenshot-label">Weather Satellite</span>
|
||||||
</div>
|
</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">
|
<div class="screenshot-item">
|
||||||
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
||||||
<span class="screenshot-label">Satellite Tracker</span>
|
<span class="screenshot-label">Satellite Tracker</span>
|
||||||
@@ -292,10 +335,10 @@
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh
|
./setup.sh # Interactive wizard with install profiles
|
||||||
sudo -E venv/bin/python intercept.py</code></pre>
|
sudo ./start.sh</code></pre>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="install-card">
|
<div class="install-card">
|
||||||
@@ -303,7 +346,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker compose up -d</code></pre>
|
docker compose --profile basic up -d --build</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,6 +355,7 @@ docker compose up -d</code></pre>
|
|||||||
<div class="post-install">
|
<div class="post-install">
|
||||||
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
||||||
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
||||||
|
<p>Run <code>./setup.sh --health-check</code> to verify your installation, or use menu option 2 for a full system health check.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -404,7 +448,7 @@ docker compose up -d</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · 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>
|
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2862,6 +2862,17 @@ class ModeManager:
|
|||||||
|
|
||||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||||
"""Parse APRS packet from direwolf or multimon-ng."""
|
"""Parse APRS packet from direwolf or multimon-ng."""
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize common decoder prefixes before parsing.
|
||||||
|
# multimon-ng: "AFSK1200: ..."
|
||||||
|
# direwolf: "[0.4] ...", "[0L] ..."
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('AFSK1200:'):
|
||||||
|
line = line[9:].strip()
|
||||||
|
line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line)
|
||||||
|
|
||||||
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
||||||
if not match:
|
if not match:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.19.0"
|
version = "2.24.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -44,3 +44,10 @@ cryptography>=41.0.0
|
|||||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||||
flask-sock
|
flask-sock
|
||||||
websocket-client>=1.6.0
|
websocket-client>=1.6.0
|
||||||
|
|
||||||
|
# System health monitoring (optional - graceful fallback if unavailable)
|
||||||
|
psutil>=5.9.0
|
||||||
|
|
||||||
|
# Production WSGI server (optional - falls back to Flask dev server)
|
||||||
|
gunicorn>=21.2.0
|
||||||
|
gevent>=23.9.0
|
||||||
|
|||||||
+45
-31
@@ -2,39 +2,46 @@
|
|||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
from .pager import pager_bp
|
from .acars import acars_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 .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .ais import ais_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 .alerts import alerts_bp
|
||||||
from .recordings import recordings_bp
|
from .aprs import aprs_bp
|
||||||
from .subghz import subghz_bp
|
from .bluetooth import bluetooth_bp
|
||||||
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
from .bt_locate import bt_locate_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(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -53,7 +60,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(receiver_bp)
|
||||||
app.register_blueprint(meshtastic_bp)
|
app.register_blueprint(meshtastic_bp)
|
||||||
app.register_blueprint(tscm_bp)
|
app.register_blueprint(tscm_bp)
|
||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
@@ -63,12 +70,19 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||||
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
|
||||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
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
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
+84
-45
@@ -13,33 +13,35 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
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
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.acars_translator import translate_message
|
||||||
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.constants import (
|
from utils.constants import (
|
||||||
|
PROCESS_START_WAIT,
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
PROCESS_START_WAIT,
|
|
||||||
)
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
|
|
||||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||||
|
|
||||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
# Default VHF ACARS frequencies (MHz) - North America primary
|
||||||
DEFAULT_ACARS_FREQUENCIES = [
|
DEFAULT_ACARS_FREQUENCIES = [
|
||||||
'131.550', # Primary worldwide
|
'131.550', # Primary worldwide / North America
|
||||||
'130.025', # Secondary USA/Canada
|
'130.025', # North America secondary
|
||||||
'129.125', # USA
|
'129.125', # North America tertiary
|
||||||
'131.525', # Europe
|
'131.725', # North America (major US carriers)
|
||||||
'131.725', # Europe secondary
|
'131.825', # North America (major US carriers)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Message counter for statistics
|
# Message counter for statistics
|
||||||
@@ -48,6 +50,7 @@ acars_last_message_time = None
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
acars_active_device: int | None = None
|
acars_active_device: int | None = None
|
||||||
|
acars_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def find_acarsdec():
|
def find_acarsdec():
|
||||||
@@ -123,12 +126,27 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
data['type'] = 'acars'
|
data['type'] = 'acars'
|
||||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
# Enrich with translated label and parsed fields
|
||||||
|
try:
|
||||||
|
translation = translate_message(data)
|
||||||
|
data['label_description'] = translation['label_description']
|
||||||
|
data['message_type'] = translation['message_type']
|
||||||
|
data['parsed'] = translation['parsed']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Update stats
|
# Update stats
|
||||||
acars_message_count += 1
|
acars_message_count += 1
|
||||||
acars_last_message_time = time.time()
|
acars_last_message_time = time.time()
|
||||||
|
|
||||||
app_module.acars_queue.put(data)
|
app_module.acars_queue.put(data)
|
||||||
|
|
||||||
|
# Feed flight correlator
|
||||||
|
try:
|
||||||
|
get_flight_correlator().add_acars_message(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -147,7 +165,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
logger.error(f"ACARS stream error: {e}")
|
logger.error(f"ACARS stream error: {e}")
|
||||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global acars_active_device
|
global acars_active_device, acars_active_sdr_type
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
@@ -163,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device)
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/tools')
|
@acars_bp.route('/tools')
|
||||||
@@ -196,7 +215,7 @@ def acars_status() -> Response:
|
|||||||
@acars_bp.route('/start', methods=['POST'])
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
def start_acars() -> Response:
|
def start_acars() -> Response:
|
||||||
"""Start ACARS decoder."""
|
"""Start ACARS decoder."""
|
||||||
global acars_message_count, acars_last_message_time, acars_active_device
|
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
@@ -223,9 +242,12 @@ def start_acars() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'acars')
|
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -234,6 +256,7 @@ def start_acars() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
acars_active_device = device_int
|
acars_active_device = device_int
|
||||||
|
acars_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
@@ -251,8 +274,6 @@ def start_acars() -> Response:
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
# Resolve SDR type for device selection
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -339,14 +360,17 @@ def start_acars() -> Response:
|
|||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died - release device
|
# Process died - release device
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device)
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
error_msg = f'acarsdec failed to start'
|
|
||||||
if stderr:
|
if stderr:
|
||||||
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)
|
logger.error(error_msg)
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
@@ -371,8 +395,9 @@ def start_acars() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device)
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -380,7 +405,7 @@ def start_acars() -> Response:
|
|||||||
@acars_bp.route('/stop', methods=['POST'])
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
def stop_acars() -> Response:
|
def stop_acars() -> Response:
|
||||||
"""Stop ACARS decoder."""
|
"""Stop ACARS decoder."""
|
||||||
global acars_active_device
|
global acars_active_device, acars_active_sdr_type
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if not app_module.acars_process:
|
if not app_module.acars_process:
|
||||||
@@ -401,8 +426,9 @@ def stop_acars() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if acars_active_device is not None:
|
if acars_active_device is not None:
|
||||||
app_module.release_sdr_device(acars_active_device)
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
acars_active_device = None
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -410,37 +436,50 @@ def stop_acars() -> Response:
|
|||||||
@acars_bp.route('/stream')
|
@acars_bp.route('/stream')
|
||||||
def stream_acars() -> Response:
|
def stream_acars() -> Response:
|
||||||
"""SSE stream for ACARS messages."""
|
"""SSE stream for ACARS messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('acars', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.acars_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='acars',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('acars', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/messages')
|
||||||
|
def get_acars_messages() -> Response:
|
||||||
|
"""Get recent ACARS messages from correlator (for history reload)."""
|
||||||
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
limit = max(1, min(limit, 200))
|
||||||
|
msgs = get_flight_correlator().get_recent_messages('acars', limit)
|
||||||
|
return jsonify(msgs)
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/clear', methods=['POST'])
|
||||||
|
def clear_acars_messages() -> Response:
|
||||||
|
"""Clear stored ACARS messages and reset counter."""
|
||||||
|
global acars_message_count, acars_last_message_time
|
||||||
|
get_flight_correlator().clear_acars()
|
||||||
|
acars_message_count = 0
|
||||||
|
acars_last_message_time = None
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/frequencies')
|
@acars_bp.route('/frequencies')
|
||||||
def get_frequencies() -> Response:
|
def get_frequencies() -> Response:
|
||||||
"""Get default ACARS frequencies."""
|
"""Get default ACARS frequencies."""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||||
'regions': {
|
'regions': {
|
||||||
'north_america': ['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'],
|
'europe': ['131.525', '131.725', '131.550'],
|
||||||
'asia_pacific': ['131.550', '131.450'],
|
'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 config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -44,6 +44,7 @@ ais_connected = False
|
|||||||
ais_messages_received = 0
|
ais_messages_received = 0
|
||||||
ais_last_message_time = None
|
ais_last_message_time = None
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
|
ais_active_sdr_type: str | None = None
|
||||||
_ais_error_logged = True
|
_ais_error_logged = True
|
||||||
|
|
||||||
# Common installation paths for AIS-catcher
|
# Common installation paths for AIS-catcher
|
||||||
@@ -79,6 +80,7 @@ def parse_ais_stream(port: int):
|
|||||||
_ais_error_logged = True
|
_ais_error_logged = True
|
||||||
|
|
||||||
while ais_running:
|
while ais_running:
|
||||||
|
sock = None
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||||
@@ -124,20 +126,33 @@ def parse_ais_stream(port: int):
|
|||||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||||
for mmsi in pending_updates:
|
for mmsi in pending_updates:
|
||||||
if mmsi in app_module.ais_vessels:
|
if mmsi in app_module.ais_vessels:
|
||||||
|
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||||
try:
|
try:
|
||||||
app_module.ais_queue.put_nowait({
|
app_module.ais_queue.put_nowait({
|
||||||
'type': 'vessel',
|
'type': 'vessel',
|
||||||
**app_module.ais_vessels[mmsi]
|
**_vessel_snap
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
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()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sock.close()
|
|
||||||
ais_connected = False
|
ais_connected = False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
ais_connected = False
|
ais_connected = False
|
||||||
@@ -145,6 +160,12 @@ def parse_ais_stream(port: int):
|
|||||||
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||||
_ais_error_logged = True
|
_ais_error_logged = True
|
||||||
time.sleep(AIS_RECONNECT_DELAY)
|
time.sleep(AIS_RECONNECT_DELAY)
|
||||||
|
finally:
|
||||||
|
if sock:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
ais_connected = False
|
ais_connected = False
|
||||||
logger.info("AIS stream parser stopped")
|
logger.info("AIS stream parser stopped")
|
||||||
@@ -282,6 +303,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
|||||||
# Timestamp
|
# Timestamp
|
||||||
vessel['last_seen'] = time.time()
|
vessel['last_seen'] = time.time()
|
||||||
|
|
||||||
|
# Check for DSC DISTRESS matching this MMSI
|
||||||
|
try:
|
||||||
|
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||||
|
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||||
|
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||||
|
vessel['dsc_distress'] = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return vessel
|
return vessel
|
||||||
|
|
||||||
|
|
||||||
@@ -326,7 +357,7 @@ def ais_status():
|
|||||||
@ais_bp.route('/start', methods=['POST'])
|
@ais_bp.route('/start', methods=['POST'])
|
||||||
def start_ais():
|
def start_ais():
|
||||||
"""Start AIS tracking."""
|
"""Start AIS tracking."""
|
||||||
global ais_running, ais_active_device
|
global ais_running, ais_active_device, ais_active_sdr_type
|
||||||
|
|
||||||
with app_module.ais_lock:
|
with app_module.ais_lock:
|
||||||
if ais_running:
|
if ais_running:
|
||||||
@@ -373,7 +404,7 @@ def start_ais():
|
|||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'ais')
|
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -412,20 +443,23 @@ def start_ais():
|
|||||||
|
|
||||||
if app_module.ais_process.poll() is not None:
|
if app_module.ais_process.poll() is not None:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.ais_process.stderr:
|
if app_module.ais_process.stderr:
|
||||||
try:
|
try:
|
||||||
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if stderr_output:
|
||||||
|
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
|
||||||
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f' Error: {stderr_output[:200]}'
|
error_msg += f' Error: {stderr_output[:500]}'
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
ais_running = True
|
ais_running = True
|
||||||
ais_active_device = device
|
ais_active_device = device
|
||||||
|
ais_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Start TCP parser thread
|
# Start TCP parser thread
|
||||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||||
@@ -439,7 +473,7 @@ def start_ais():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -447,7 +481,7 @@ def start_ais():
|
|||||||
@ais_bp.route('/stop', methods=['POST'])
|
@ais_bp.route('/stop', methods=['POST'])
|
||||||
def stop_ais():
|
def stop_ais():
|
||||||
"""Stop AIS tracking."""
|
"""Stop AIS tracking."""
|
||||||
global ais_running, ais_active_device
|
global ais_running, ais_active_device, ais_active_sdr_type
|
||||||
|
|
||||||
with app_module.ais_lock:
|
with app_module.ais_lock:
|
||||||
if app_module.ais_process:
|
if app_module.ais_process:
|
||||||
@@ -466,10 +500,11 @@ def stop_ais():
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if ais_active_device is not None:
|
if ais_active_device is not None:
|
||||||
app_module.release_sdr_device(ais_active_device)
|
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
|
||||||
|
|
||||||
ais_running = False
|
ais_running = False
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
|
ais_active_sdr_type = None
|
||||||
|
|
||||||
app_module.ais_vessels.clear()
|
app_module.ais_vessels.clear()
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
@@ -478,34 +513,47 @@ def stop_ais():
|
|||||||
@ais_bp.route('/stream')
|
@ais_bp.route('/stream')
|
||||||
def stream_ais():
|
def stream_ais():
|
||||||
"""SSE stream for AIS vessels."""
|
"""SSE stream for AIS vessels."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('ais', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.ais_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='ais',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('ais', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||||
|
def get_vessel_dsc(mmsi: str):
|
||||||
|
"""Get DSC messages associated with a vessel MMSI."""
|
||||||
|
if not mmsi or not mmsi.isdigit():
|
||||||
|
return 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')
|
@ais_bp.route('/dashboard')
|
||||||
def ais_dashboard():
|
def ais_dashboard():
|
||||||
"""Popout AIS dashboard."""
|
"""Popout AIS dashboard."""
|
||||||
|
embedded = request.args.get('embedded', 'false') == 'true'
|
||||||
return render_template(
|
return render_template(
|
||||||
'ais_dashboard.html',
|
'ais_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
embedded=embedded,
|
||||||
)
|
)
|
||||||
|
|||||||
+363
-118
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import select
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -14,14 +16,20 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from subprocess import PIPE, STDOUT
|
from subprocess import PIPE, STDOUT
|
||||||
from typing import Generator, Optional
|
from typing import Any, Generator, Optional
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import (
|
||||||
from utils.sse import format_sse
|
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.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -35,6 +43,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
|||||||
|
|
||||||
# Track which SDR device is being used
|
# Track which SDR device is being used
|
||||||
aprs_active_device: int | None = None
|
aprs_active_device: int | None = None
|
||||||
|
aprs_active_sdr_type: str | None = None
|
||||||
|
|
||||||
# APRS frequencies by region (MHz)
|
# APRS frequencies by region (MHz)
|
||||||
APRS_FREQUENCIES = {
|
APRS_FREQUENCIES = {
|
||||||
@@ -47,6 +56,8 @@ APRS_FREQUENCIES = {
|
|||||||
'brazil': '145.570',
|
'brazil': '145.570',
|
||||||
'japan': '144.640',
|
'japan': '144.640',
|
||||||
'china': '144.640',
|
'china': '144.640',
|
||||||
|
'iss': '145.825',
|
||||||
|
'sonate2': '145.825',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
@@ -101,12 +112,35 @@ ADEVICE stdin null
|
|||||||
CHANNEL 0
|
CHANNEL 0
|
||||||
MYCALL N0CALL
|
MYCALL N0CALL
|
||||||
MODEM 1200
|
MODEM 1200
|
||||||
|
FIX_BITS 1
|
||||||
|
AGWPORT 0
|
||||||
|
KISSPORT 0
|
||||||
"""
|
"""
|
||||||
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
||||||
f.write(config)
|
f.write(config)
|
||||||
return DIREWOLF_CONFIG_PATH
|
return DIREWOLF_CONFIG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_aprs_output_line(line: str) -> str:
|
||||||
|
"""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]:
|
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||||
"""Parse APRS packet into structured data.
|
"""Parse APRS packet into structured data.
|
||||||
|
|
||||||
@@ -123,10 +157,15 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
|||||||
- User-defined formats
|
- User-defined formats
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
raw_packet = normalize_aprs_output_line(raw_packet)
|
||||||
|
if not raw_packet:
|
||||||
|
return None
|
||||||
|
|
||||||
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
||||||
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
||||||
|
|
||||||
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:
|
if not match:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -442,6 +481,109 @@ def parse_position(data: str) -> Optional[dict]:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Legacy/no-decimal variant occasionally seen in degraded decodes:
|
||||||
|
# DDMMN/DDDMMW (symbol chars still present between/after coords).
|
||||||
|
nodot_match = re.match(
|
||||||
|
r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
if nodot_match:
|
||||||
|
lat_deg = int(nodot_match.group(1))
|
||||||
|
lat_min = float(nodot_match.group(2))
|
||||||
|
lat_dir = nodot_match.group(3)
|
||||||
|
symbol_table = nodot_match.group(4)
|
||||||
|
lon_deg = int(nodot_match.group(5))
|
||||||
|
lon_min = float(nodot_match.group(6))
|
||||||
|
lon_dir = nodot_match.group(7)
|
||||||
|
symbol_code = nodot_match.group(8) or ''
|
||||||
|
|
||||||
|
lat = lat_deg + lat_min / 60.0
|
||||||
|
if lat_dir == 'S':
|
||||||
|
lat = -lat
|
||||||
|
|
||||||
|
lon = lon_deg + lon_min / 60.0
|
||||||
|
if lon_dir == 'W':
|
||||||
|
lon = -lon
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'lat': round(lat, 6),
|
||||||
|
'lon': round(lon, 6),
|
||||||
|
'symbol': symbol_table + symbol_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = data[13:] if len(data) > 13 else ''
|
||||||
|
|
||||||
|
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
||||||
|
if cs_match:
|
||||||
|
result['course'] = int(cs_match.group(1))
|
||||||
|
result['speed'] = int(cs_match.group(2))
|
||||||
|
|
||||||
|
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
||||||
|
if alt_match:
|
||||||
|
result['altitude'] = int(alt_match.group(1))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Fallback: tolerate APRS ambiguity spaces in minute fields.
|
||||||
|
# Example: 4903. N/07201. W
|
||||||
|
if len(data) >= 18:
|
||||||
|
lat_field = data[0:7]
|
||||||
|
lat_dir = data[7]
|
||||||
|
symbol_table = data[8] if len(data) > 8 else ''
|
||||||
|
lon_field = data[9:17] if len(data) >= 17 else ''
|
||||||
|
lon_dir = data[17] if len(data) > 17 else ''
|
||||||
|
symbol_code = data[18] if len(data) > 18 else ''
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(lat_field) == 7
|
||||||
|
and len(lon_field) == 8
|
||||||
|
and lat_dir in ('N', 'S')
|
||||||
|
and lon_dir in ('E', 'W')
|
||||||
|
):
|
||||||
|
lat_deg_txt = lat_field[:2]
|
||||||
|
lat_min_txt = lat_field[2:].replace(' ', '0')
|
||||||
|
lon_deg_txt = lon_field[:3]
|
||||||
|
lon_min_txt = lon_field[3:].replace(' ', '0')
|
||||||
|
|
||||||
|
if (
|
||||||
|
lat_deg_txt.isdigit()
|
||||||
|
and lon_deg_txt.isdigit()
|
||||||
|
and re.match(r'^\d{2}\.\d+$', lat_min_txt)
|
||||||
|
and re.match(r'^\d{2}\.\d+$', lon_min_txt)
|
||||||
|
):
|
||||||
|
lat_deg = int(lat_deg_txt)
|
||||||
|
lon_deg = int(lon_deg_txt)
|
||||||
|
lat_min = float(lat_min_txt)
|
||||||
|
lon_min = float(lon_min_txt)
|
||||||
|
|
||||||
|
lat = lat_deg + lat_min / 60.0
|
||||||
|
if lat_dir == 'S':
|
||||||
|
lat = -lat
|
||||||
|
|
||||||
|
lon = lon_deg + lon_min / 60.0
|
||||||
|
if lon_dir == 'W':
|
||||||
|
lon = -lon
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'lat': round(lat, 6),
|
||||||
|
'lon': round(lon, 6),
|
||||||
|
'symbol': symbol_table + symbol_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep same extension parsing behavior as primary branch.
|
||||||
|
remaining = data[19:] if len(data) > 19 else ''
|
||||||
|
|
||||||
|
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
||||||
|
if cs_match:
|
||||||
|
result['course'] = int(cs_match.group(1))
|
||||||
|
result['speed'] = int(cs_match.group(2))
|
||||||
|
|
||||||
|
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
||||||
|
if alt_match:
|
||||||
|
result['altitude'] = int(alt_match.group(1))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to parse position: {e}")
|
logger.debug(f"Failed to parse position: {e}")
|
||||||
|
|
||||||
@@ -1307,19 +1449,23 @@ def should_send_meter_update(level: int) -> bool:
|
|||||||
return False
|
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.
|
"""Stream decoded APRS packets and audio level meter to queue.
|
||||||
|
|
||||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
Reads from a PTY master fd to get line-buffered output from the decoder,
|
||||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
|
||||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
to poll the PTY (same pattern as pager.py).
|
||||||
|
|
||||||
Outputs two types of messages to the queue:
|
Outputs two types of messages to the queue:
|
||||||
- type='aprs': Decoded APRS packets
|
- type='aprs': Decoded APRS packets
|
||||||
- type='meter': Audio level meter readings (rate-limited)
|
- type='meter': Audio level meter readings (rate-limited)
|
||||||
"""
|
"""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
global _last_meter_time, _last_meter_level
|
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
|
# Reset meter state
|
||||||
_last_meter_time = 0.0
|
_last_meter_time = 0.0
|
||||||
@@ -1328,80 +1474,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
try:
|
try:
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
# Read line-by-line in text mode. Empty string '' signals EOF.
|
# Read from PTY using select() for non-blocking reads.
|
||||||
for line in iter(decoder_process.stdout.readline, ''):
|
# PTY forces the decoder to line-buffer, so output arrives immediately
|
||||||
line = line.strip()
|
# instead of waiting for a full 4-8KB pipe buffer to fill.
|
||||||
if not line:
|
buffer = ""
|
||||||
continue
|
while True:
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
# Check for audio level line first (for signal meter)
|
if ready:
|
||||||
audio_level = parse_audio_level(line)
|
try:
|
||||||
if audio_level is not None:
|
data = os.read(master_fd, 1024)
|
||||||
if should_send_meter_update(audio_level):
|
if not data:
|
||||||
meter_msg = {
|
break
|
||||||
'type': 'meter',
|
buffer += data.decode('utf-8', errors='replace')
|
||||||
'level': audio_level,
|
except OSError:
|
||||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
break
|
||||||
}
|
|
||||||
app_module.aprs_queue.put(meter_msg)
|
|
||||||
continue # Audio level lines are not packets
|
|
||||||
|
|
||||||
# multimon-ng prefixes decoded packets with "AFSK1200: "
|
while '\n' in buffer:
|
||||||
if line.startswith('AFSK1200:'):
|
line, buffer = buffer.split('\n', 1)
|
||||||
line = line[9:].strip()
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
# direwolf often prefixes packets with "[0.4] " or similar audio level indicator
|
# Check for audio level line first (for signal meter)
|
||||||
# Strip any leading bracket prefix like "[0.4] " before parsing
|
audio_level = parse_audio_level(line)
|
||||||
line = re.sub(r'^\[\d+\.\d+\]\s*', '', 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)
|
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||||
if '>' not in line or ':' not in line:
|
line = normalize_aprs_output_line(line)
|
||||||
continue
|
|
||||||
|
|
||||||
packet = parse_aprs_packet(line)
|
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||||
if packet:
|
if '>' not in line or ':' not in line:
|
||||||
aprs_packet_count += 1
|
continue
|
||||||
aprs_last_packet_time = time.time()
|
|
||||||
|
|
||||||
# Track unique stations
|
packet = parse_aprs_packet(line)
|
||||||
callsign = packet.get('callsign')
|
if packet:
|
||||||
if callsign and callsign not in aprs_stations:
|
aprs_packet_count += 1
|
||||||
aprs_station_count += 1
|
aprs_last_packet_time = time.time()
|
||||||
|
|
||||||
# Update station data
|
# Track unique stations
|
||||||
if callsign:
|
callsign = packet.get('callsign')
|
||||||
aprs_stations[callsign] = {
|
if callsign and callsign not in aprs_stations:
|
||||||
'callsign': callsign,
|
aprs_station_count += 1
|
||||||
'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]
|
|
||||||
|
|
||||||
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
|
app_module.aprs_queue.put(packet)
|
||||||
if app_module.logging_enabled:
|
|
||||||
try:
|
# Log if enabled
|
||||||
with open(app_module.log_file_path, 'a') as f:
|
if app_module.logging_enabled:
|
||||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
try:
|
||||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
except Exception:
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
pass
|
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"APRS stream error: {e}")
|
logger.error(f"APRS stream error: {e}")
|
||||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global aprs_active_device
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
# Cleanup processes
|
# Cleanup processes
|
||||||
for proc in [rtl_process, decoder_process]:
|
for proc in [rtl_process, decoder_process]:
|
||||||
@@ -1413,10 +1593,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Release SDR device
|
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
||||||
if aprs_active_device is not None:
|
if my_device is not None and aprs_active_device == my_device:
|
||||||
app_module.release_sdr_device(aprs_active_device)
|
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/tools')
|
@aprs_bp.route('/tools')
|
||||||
@@ -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'])
|
@aprs_bp.route('/start', methods=['POST'])
|
||||||
def start_aprs() -> Response:
|
def start_aprs() -> Response:
|
||||||
"""Start APRS decoder."""
|
"""Start APRS decoder."""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
global aprs_active_device
|
global aprs_active_device, aprs_active_sdr_type
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||||
@@ -1496,6 +1695,10 @@ def start_aprs() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
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)
|
||||||
|
|
||||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
@@ -1515,15 +1718,17 @@ def start_aprs() -> Response:
|
|||||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Reserve SDR device to prevent conflicts with other modes
|
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
||||||
error = app_module.claim_sdr_device(device, 'aprs')
|
if not rtl_tcp_host:
|
||||||
if error:
|
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||||
return jsonify({
|
if error:
|
||||||
'status': 'error',
|
return jsonify({
|
||||||
'error_type': 'DEVICE_BUSY',
|
'status': 'error',
|
||||||
'message': error
|
'error_type': 'DEVICE_BUSY',
|
||||||
}), 409
|
'message': error
|
||||||
aprs_active_device = device
|
}), 409
|
||||||
|
aprs_active_device = device
|
||||||
|
aprs_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Get frequency for region
|
# Get frequency for region
|
||||||
region = data.get('region', 'north_america')
|
region = data.get('region', 'north_america')
|
||||||
@@ -1547,8 +1752,17 @@ def start_aprs() -> Response:
|
|||||||
|
|
||||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||||
try:
|
try:
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
if rtl_tcp_host:
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
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(
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
device=sdr_device,
|
device=sdr_device,
|
||||||
frequency_mhz=float(frequency),
|
frequency_mhz=float(frequency),
|
||||||
@@ -1565,8 +1779,9 @@ def start_aprs() -> Response:
|
|||||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||||
|
|
||||||
# Build decoder command
|
# Build decoder command
|
||||||
@@ -1620,19 +1835,25 @@ def start_aprs() -> Response:
|
|||||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||||
rtl_stderr_thread.start()
|
rtl_stderr_thread.start()
|
||||||
|
|
||||||
|
# Create a pseudo-terminal for decoder output. PTY forces the
|
||||||
|
# decoder to line-buffer its stdout, avoiding the 15-minute delay
|
||||||
|
# caused by full pipe buffering (~4-8KB) on small APRS packets.
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||||
# Use text mode with line buffering for reliable line-by-line reading.
|
# stdout/stderr go to the PTY slave so output is line-buffered.
|
||||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
|
||||||
decoder_process = subprocess.Popen(
|
decoder_process = subprocess.Popen(
|
||||||
decoder_cmd,
|
decoder_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=rtl_process.stdout,
|
||||||
stdout=PIPE,
|
stdout=slave_fd,
|
||||||
stderr=STDOUT,
|
stderr=slave_fd,
|
||||||
text=True,
|
close_fds=True,
|
||||||
bufsize=1,
|
|
||||||
start_new_session=True
|
start_new_session=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Close slave fd in parent — decoder owns it now.
|
||||||
|
os.close(slave_fd)
|
||||||
|
|
||||||
# Close rtl_fm's stdout in parent so decoder owns it exclusively.
|
# Close rtl_fm's stdout in parent so decoder owns it exclusively.
|
||||||
# This ensures proper EOF propagation when rtl_fm terminates.
|
# This ensures proper EOF propagation when rtl_fm terminates.
|
||||||
rtl_process.stdout.close()
|
rtl_process.stdout.close()
|
||||||
@@ -1649,43 +1870,63 @@ def start_aprs() -> Response:
|
|||||||
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if stderr_output:
|
||||||
|
logger.error(f"rtl_fm stderr:\n{stderr_output}")
|
||||||
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
|
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
|
||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f': {stderr_output[:200]}'
|
error_msg += f': {stderr_output[:500]}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
decoder_process.kill()
|
decoder_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
if decoder_process.poll() is not None:
|
if decoder_process.poll() is not None:
|
||||||
# Decoder exited early - capture any output
|
# Decoder exited early - capture any output from PTY
|
||||||
error_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else ''
|
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'
|
error_msg = f'{decoder_name} failed to start'
|
||||||
if error_output:
|
if error_output:
|
||||||
error_msg += f': {error_output}'
|
error_msg += f': {error_output}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
# Store references for status checks and cleanup
|
# Store references for status checks and cleanup
|
||||||
app_module.aprs_process = decoder_process
|
app_module.aprs_process = decoder_process
|
||||||
app_module.aprs_rtl_process = rtl_process
|
app_module.aprs_rtl_process = rtl_process
|
||||||
|
app_module.aprs_master_fd = master_fd
|
||||||
|
|
||||||
# Start background thread to read decoder output and push to queue
|
# Start background thread to read decoder output and push to queue
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=stream_aprs_output,
|
target=stream_aprs_output,
|
||||||
args=(rtl_process, decoder_process),
|
args=(master_fd, rtl_process, decoder_process),
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -1702,15 +1943,16 @@ def start_aprs() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start APRS decoder: {e}")
|
logger.error(f"Failed to start APRS decoder: {e}")
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
app_module.release_sdr_device(aprs_active_device)
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/stop', methods=['POST'])
|
@aprs_bp.route('/stop', methods=['POST'])
|
||||||
def stop_aprs() -> Response:
|
def stop_aprs() -> Response:
|
||||||
"""Stop APRS decoder."""
|
"""Stop APRS decoder."""
|
||||||
global aprs_active_device
|
global aprs_active_device, aprs_active_sdr_type
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
processes_to_stop = []
|
processes_to_stop = []
|
||||||
@@ -1736,14 +1978,23 @@ def stop_aprs() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping APRS process: {e}")
|
logger.error(f"Error stopping APRS process: {e}")
|
||||||
|
|
||||||
|
# Close PTY master fd
|
||||||
|
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(app_module.aprs_master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
app_module.aprs_master_fd = None
|
||||||
|
|
||||||
app_module.aprs_process = None
|
app_module.aprs_process = None
|
||||||
if hasattr(app_module, 'aprs_rtl_process'):
|
if hasattr(app_module, 'aprs_rtl_process'):
|
||||||
app_module.aprs_rtl_process = None
|
app_module.aprs_rtl_process = None
|
||||||
|
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
app_module.release_sdr_device(aprs_active_device)
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -1751,25 +2002,19 @@ def stop_aprs() -> Response:
|
|||||||
@aprs_bp.route('/stream')
|
@aprs_bp.route('/stream')
|
||||||
def stream_aprs() -> Response:
|
def stream_aprs() -> Response:
|
||||||
"""SSE stream for APRS packets."""
|
"""SSE stream for APRS packets."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('aprs', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.aprs_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='aprs',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('aprs', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|||||||
+13
-20
@@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
@@ -556,26 +556,19 @@ def get_bt_devices():
|
|||||||
@bluetooth_bp.route('/stream')
|
@bluetooth_bp.route('/stream')
|
||||||
def stream_bt():
|
def stream_bt():
|
||||||
"""SSE stream for Bluetooth events."""
|
"""SSE stream for Bluetooth events."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
source_queue=app_module.bt_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='bluetooth',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('bluetooth', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ def start_scan():
|
|||||||
# Check if already scanning
|
# Check if already scanning
|
||||||
if scanner.is_scanning:
|
if scanner.is_scanning:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_scanning',
|
||||||
'scan_status': scanner.get_status().to_dict()
|
'scan_status': scanner.get_status().to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+36
-6
@@ -38,6 +38,8 @@ def start_session():
|
|||||||
- name_pattern: Target name substring (optional)
|
- name_pattern: Target name substring (optional)
|
||||||
- irk_hex: Identity Resolving Key hex string (optional)
|
- irk_hex: Identity Resolving Key hex string (optional)
|
||||||
- device_id: Device ID from Bluetooth scanner (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_name: Hand-off device name (optional)
|
||||||
- known_manufacturer: Hand-off manufacturer (optional)
|
- known_manufacturer: Hand-off manufacturer (optional)
|
||||||
- last_known_rssi: Hand-off last RSSI (optional)
|
- last_known_rssi: Hand-off last RSSI (optional)
|
||||||
@@ -55,14 +57,28 @@ def start_session():
|
|||||||
name_pattern=data.get('name_pattern'),
|
name_pattern=data.get('name_pattern'),
|
||||||
irk_hex=data.get('irk_hex'),
|
irk_hex=data.get('irk_hex'),
|
||||||
device_id=data.get('device_id'),
|
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_name=data.get('known_name'),
|
||||||
known_manufacturer=data.get('known_manufacturer'),
|
known_manufacturer=data.get('known_manufacturer'),
|
||||||
last_known_rssi=data.get('last_known_rssi'),
|
last_known_rssi=data.get('last_known_rssi'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# At least one identifier required
|
# At least one identifier required
|
||||||
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]):
|
if not any([
|
||||||
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
|
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
|
# Parse environment
|
||||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||||
@@ -93,9 +109,22 @@ def start_session():
|
|||||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||||
)
|
)
|
||||||
|
|
||||||
session = start_locate_session(
|
try:
|
||||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
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({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
@@ -124,7 +153,8 @@ def get_status():
|
|||||||
'target': None,
|
'target': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify(session.get_status())
|
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'])
|
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||||
|
|||||||
+49
-25
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
@@ -38,8 +39,26 @@ logger = logging.getLogger('intercept.controller')
|
|||||||
|
|
||||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
|
||||||
# Multi-agent data queue for combined SSE stream
|
# Multi-agent SSE fanout state (per-client queues).
|
||||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
_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')
|
received_at=data.get('received_at')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Emit to SSE stream
|
# Emit to SSE stream (fanout to all connected clients)
|
||||||
try:
|
_broadcast_agent_data({
|
||||||
agent_data_queue.put_nowait({
|
'type': 'agent_data',
|
||||||
'type': 'agent_data',
|
'agent_id': agent['id'],
|
||||||
'agent_id': agent['id'],
|
'agent_name': agent_name,
|
||||||
'agent_name': agent_name,
|
'scan_type': data.get('scan_type'),
|
||||||
'scan_type': data.get('scan_type'),
|
'interface': data.get('interface'),
|
||||||
'interface': data.get('interface'),
|
'payload': data.get('payload'),
|
||||||
'payload': data.get('payload'),
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
})
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
logger.warning("Agent data queue full, data may be lost")
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'accepted',
|
'status': 'accepted',
|
||||||
@@ -681,20 +697,28 @@ def stream_all_agents():
|
|||||||
This endpoint streams push data as it arrives from agents.
|
This endpoint streams push data as it arrives from agents.
|
||||||
Each message is tagged with agent_id and agent_name.
|
Each message is tagged with agent_id and agent_name.
|
||||||
"""
|
"""
|
||||||
|
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
_agent_stream_subscribers.add(client_queue)
|
||||||
|
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
while True:
|
try:
|
||||||
try:
|
while True:
|
||||||
msg = agent_data_queue.get(timeout=1.0)
|
try:
|
||||||
last_keepalive = time.time()
|
msg = client_queue.get(timeout=1.0)
|
||||||
yield format_sse(msg)
|
last_keepalive = time.time()
|
||||||
except queue.Empty:
|
yield format_sse(msg)
|
||||||
now = time.time()
|
except queue.Empty:
|
||||||
if now - last_keepalive >= keepalive_interval:
|
now = time.time()
|
||||||
yield format_sse({'type': 'keepalive'})
|
if now - last_keepalive >= keepalive_interval:
|
||||||
last_keepalive = now
|
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 = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
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,
|
get_dsc_alert_summary,
|
||||||
)
|
)
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
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.sdr import SDRFactory, SDRType
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
@@ -51,6 +56,7 @@ dsc_running = False
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
dsc_active_device: int | None = None
|
dsc_active_device: int | None = None
|
||||||
|
dsc_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_dsc_decoder_path() -> str | None:
|
def _get_dsc_decoder_path() -> str | None:
|
||||||
@@ -171,7 +177,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
global dsc_active_device
|
global dsc_active_device, dsc_active_sdr_type
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -197,8 +203,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
app_module.dsc_rtl_process = None
|
app_module.dsc_rtl_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device)
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
def _store_critical_alert(msg: dict) -> None:
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
@@ -331,18 +338,32 @@ def start_decoding() -> Response:
|
|||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Check if device is available using centralized registry
|
# Get SDR type from request
|
||||||
global dsc_active_device
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
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
|
|
||||||
|
|
||||||
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
|
# Clear queue
|
||||||
while not app_module.dsc_queue.empty():
|
while not app_module.dsc_queue.empty():
|
||||||
@@ -351,22 +372,32 @@ def start_decoding() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Build rtl_fm command
|
# Build rtl_fm command via SDR abstraction layer
|
||||||
rtl_fm_path = tools['rtl_fm']['path']
|
|
||||||
decoder_path = tools['dsc_decoder']['path']
|
decoder_path = tools['dsc_decoder']['path']
|
||||||
|
|
||||||
# rtl_fm command for DSC decoding
|
if rtl_tcp_host:
|
||||||
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
try:
|
||||||
rtl_cmd = [
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_fm_path,
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
except ValueError as e:
|
||||||
'-s', str(DSC_SAMPLE_RATE),
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
'-d', str(device),
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
'-g', str(gain),
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
'-M', 'fm', # FM demodulation
|
else:
|
||||||
'-l', '0', # No squelch for DSC
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
|
||||||
'-E', 'dc' # DC blocking filter
|
|
||||||
]
|
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||||
|
rtl_cmd = list(builder.build_fm_demod_command(
|
||||||
|
device=sdr_device,
|
||||||
|
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
sample_rate=DSC_SAMPLE_RATE,
|
||||||
|
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||||
|
modulation='fm',
|
||||||
|
squelch=0,
|
||||||
|
))
|
||||||
|
# Ensure trailing '-' for stdin piping and add DC blocking filter
|
||||||
|
if rtl_cmd and rtl_cmd[-1] == '-':
|
||||||
|
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
|
||||||
|
|
||||||
# Decoder command
|
# Decoder command
|
||||||
decoder_cmd = [decoder_path]
|
decoder_cmd = [decoder_path]
|
||||||
@@ -440,8 +471,9 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device)
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Tool not found: {e.filename}'
|
'message': f'Tool not found: {e.filename}'
|
||||||
@@ -458,8 +490,9 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device)
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
logger.error(f"Failed to start DSC decoder: {e}")
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -470,7 +503,7 @@ def start_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stop', methods=['POST'])
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
"""Stop DSC decoder."""
|
"""Stop DSC decoder."""
|
||||||
global dsc_running, dsc_active_device
|
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
||||||
|
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
if not app_module.dsc_process:
|
if not app_module.dsc_process:
|
||||||
@@ -509,8 +542,9 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if dsc_active_device is not None:
|
if dsc_active_device is not None:
|
||||||
app_module.release_sdr_device(dsc_active_device)
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
dsc_active_device = None
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -518,26 +552,19 @@ def stop_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stream')
|
@dsc_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
"""SSE stream for real-time DSC messages."""
|
"""SSE stream for real-time DSC messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('dsc', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.dsc_queue.get(timeout=1)
|
source_queue=app_module.dsc_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='dsc',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('dsc', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
+16
-19
@@ -21,7 +21,7 @@ from utils.gps import (
|
|||||||
stop_gpsd_daemon,
|
stop_gpsd_daemon,
|
||||||
)
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -68,6 +68,9 @@ def auto_connect_gps():
|
|||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
|
# Ensure stream callbacks are attached for this process.
|
||||||
|
reader.add_callback(_position_callback)
|
||||||
|
reader.add_sky_callback(_sky_callback)
|
||||||
position = reader.position
|
position = reader.position
|
||||||
sky = reader.sky
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -211,9 +214,10 @@ def get_satellites():
|
|||||||
|
|
||||||
if not reader or not reader.is_running:
|
if not reader or not reader.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'waiting',
|
||||||
|
'running': False,
|
||||||
'message': 'GPS client not running'
|
'message': 'GPS client not running'
|
||||||
}), 400
|
})
|
||||||
|
|
||||||
sky = reader.sky
|
sky = reader.sky
|
||||||
if sky:
|
if sky:
|
||||||
@@ -231,22 +235,15 @@ def get_satellites():
|
|||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position and sky updates."""
|
"""SSE stream of GPS position and sky updates."""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0
|
source_queue=_gps_queue,
|
||||||
|
channel_key='gps',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
data = _gps_queue.get(timeout=1)
|
),
|
||||||
last_keepalive = time.time()
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
+838
-366
File diff suppressed because it is too large
Load Diff
+26
-17
@@ -17,7 +17,7 @@ from typing import Generator
|
|||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.meshtastic import (
|
from utils.meshtastic import (
|
||||||
get_meshtastic_client,
|
get_meshtastic_client,
|
||||||
start_meshtastic,
|
start_meshtastic,
|
||||||
@@ -469,22 +469,15 @@ def stream_messages():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0
|
source_queue=_mesh_queue,
|
||||||
|
channel_key='meshtastic',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
msg = _mesh_queue.get(timeout=1)
|
),
|
||||||
last_keepalive = time.time()
|
mimetype='text/event-stream',
|
||||||
yield format_sse(msg)
|
)
|
||||||
except queue.Empty:
|
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
@@ -1051,3 +1044,19 @@ def request_store_forward():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': error or 'Failed to request S&F history'
|
'message': error or 'Failed to request S&F history'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/topology')
|
||||||
|
def mesh_topology():
|
||||||
|
"""Return mesh network topology graph."""
|
||||||
|
if not is_meshtastic_available():
|
||||||
|
return 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
+3
-2
@@ -11,8 +11,9 @@ offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
|||||||
# Default offline settings
|
# Default offline settings
|
||||||
OFFLINE_DEFAULTS = {
|
OFFLINE_DEFAULTS = {
|
||||||
'offline.enabled': False,
|
'offline.enabled': False,
|
||||||
'offline.assets_source': 'cdn',
|
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||||
'offline.fonts_source': 'cdn',
|
'offline.assets_source': 'local',
|
||||||
|
'offline.fonts_source': 'local',
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
}
|
}
|
||||||
|
|||||||
+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_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
pager_active_device: int | None = None
|
pager_active_device: int | None = None
|
||||||
|
pager_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||||
@@ -54,6 +55,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
|||||||
'message': pocsag_match.group(5).strip() or '[No Message]'
|
'message': pocsag_match.group(5).strip() or '[No Message]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
|
||||||
|
pocsag_other_match = re.match(
|
||||||
|
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if pocsag_other_match:
|
||||||
|
return {
|
||||||
|
'protocol': pocsag_other_match.group(1),
|
||||||
|
'address': pocsag_other_match.group(2),
|
||||||
|
'function': pocsag_other_match.group(3),
|
||||||
|
'msg_type': pocsag_other_match.group(4),
|
||||||
|
'message': pocsag_other_match.group(5).strip() or '[No Message]'
|
||||||
|
}
|
||||||
|
|
||||||
# POCSAG parsing - address only (no message content)
|
# POCSAG parsing - address only (no message content)
|
||||||
pocsag_addr_match = re.match(
|
pocsag_addr_match = re.match(
|
||||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
||||||
@@ -108,6 +123,20 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||||
|
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||||
|
if not samples:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||||
|
waveform: list[int] = []
|
||||||
|
for sample in window:
|
||||||
|
# Convert int16 PCM to int8 range for lightweight transport.
|
||||||
|
packed = int(round(sample / 256))
|
||||||
|
waveform.append(max(-127, min(127, packed)))
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
def audio_relay_thread(
|
||||||
rtl_stdout,
|
rtl_stdout,
|
||||||
multimon_stdin,
|
multimon_stdin,
|
||||||
@@ -118,7 +147,7 @@ def audio_relay_thread(
|
|||||||
|
|
||||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
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
|
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
|
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
INTERVAL = 0.1 # seconds between scope updates
|
||||||
@@ -152,6 +181,7 @@ def audio_relay_thread(
|
|||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rms': rms,
|
'rms': rms,
|
||||||
'peak': peak,
|
'peak': peak,
|
||||||
|
'waveform': _encode_scope_waveform(samples),
|
||||||
})
|
})
|
||||||
except (struct.error, ValueError, queue.Full):
|
except (struct.error, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
@@ -205,7 +235,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global pager_active_device
|
global pager_active_device, pager_active_sdr_type
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -234,13 +264,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/start', methods=['POST'])
|
@pager_bp.route('/start', methods=['POST'])
|
||||||
def start_decoding() -> Response:
|
def start_decoding() -> Response:
|
||||||
global pager_active_device
|
global pager_active_device, pager_active_sdr_type
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
@@ -269,10 +300,13 @@ def start_decoding() -> Response:
|
|||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Get SDR type early so we can pass it to claim/release
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
# Claim local device if not using remote rtl_tcp
|
# Claim local device if not using remote rtl_tcp
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'pager')
|
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -280,14 +314,16 @@ def start_decoding() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
pager_active_device = device_int
|
pager_active_device = device_int
|
||||||
|
pager_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Validate protocols
|
# Validate protocols
|
||||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||||
protocols = data.get('protocols', valid_protocols)
|
protocols = data.get('protocols', valid_protocols)
|
||||||
if not isinstance(protocols, list):
|
if not isinstance(protocols, list):
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
@@ -312,8 +348,7 @@ def start_decoding() -> Response:
|
|||||||
elif proto == 'FLEX':
|
elif proto == 'FLEX':
|
||||||
decoders.extend(['-a', 'FLEX'])
|
decoders.extend(['-a', 'FLEX'])
|
||||||
|
|
||||||
# Get SDR type and build command via abstraction layer
|
# Build command via SDR abstraction layer
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -428,8 +463,9 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Kill orphaned rtl_fm process if it was started
|
# Kill orphaned rtl_fm process if it was started
|
||||||
@@ -443,14 +479,15 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
global pager_active_device
|
global pager_active_device, pager_active_sdr_type
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
@@ -487,8 +524,9 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -540,28 +578,19 @@ def toggle_logging() -> Response:
|
|||||||
|
|
||||||
@pager_bp.route('/stream')
|
@pager_bp.route('/stream')
|
||||||
def stream() -> Response:
|
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]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
source_queue=app_module.output_queue,
|
||||||
|
channel_key='pager',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
on_message=_on_msg,
|
||||||
last_keepalive = time.time()
|
),
|
||||||
try:
|
mimetype='text/event-stream',
|
||||||
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.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, send_file
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
@@ -107,3 +108,59 @@ def download_recording(session_id: str):
|
|||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=file_path.name,
|
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,
|
||||||
|
})
|
||||||
|
|||||||
+61
-46
@@ -17,7 +17,7 @@ from utils.logging import sensor_logger as logger
|
|||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
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
|
# Track which device is being used
|
||||||
rtlamr_active_device: int | None = None
|
rtlamr_active_device: int | None = None
|
||||||
|
rtlamr_active_sdr_type: str = 'rtlsdr'
|
||||||
|
|
||||||
|
|
||||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
@@ -62,7 +63,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global rtl_tcp_process, rtlamr_active_device
|
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||||
# Ensure rtlamr process is terminated
|
# Ensure rtlamr process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
@@ -91,19 +92,26 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.rtlamr_process = None
|
app_module.rtlamr_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||||
def start_rtlamr() -> Response:
|
def start_rtlamr() -> Response:
|
||||||
global rtl_tcp_process, rtlamr_active_device
|
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||||
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
|
if sdr_type_str != 'rtlsdr':
|
||||||
|
return 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
|
# Validate inputs
|
||||||
try:
|
try:
|
||||||
@@ -116,7 +124,7 @@ def start_rtlamr() -> Response:
|
|||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -125,6 +133,7 @@ def start_rtlamr() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
rtlamr_active_device = device_int
|
rtlamr_active_device = device_int
|
||||||
|
rtlamr_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.rtlamr_queue.empty():
|
while not app_module.rtlamr_queue.empty():
|
||||||
@@ -138,6 +147,8 @@ def start_rtlamr() -> Response:
|
|||||||
output_format = data.get('format', 'json')
|
output_format = data.get('format', 'json')
|
||||||
|
|
||||||
# Start rtl_tcp first
|
# Start rtl_tcp first
|
||||||
|
rtl_tcp_just_started = False
|
||||||
|
rtl_tcp_cmd_str = ''
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if not rtl_tcp_process:
|
if not rtl_tcp_process:
|
||||||
logger.info("Starting rtl_tcp server...")
|
logger.info("Starting rtl_tcp server...")
|
||||||
@@ -162,20 +173,22 @@ def start_rtlamr() -> Response:
|
|||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
register_process(rtl_tcp_process)
|
register_process(rtl_tcp_process)
|
||||||
|
rtl_tcp_just_started = True
|
||||||
# Wait a moment for rtl_tcp to start
|
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
|
|
||||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||||
# Release SDR device on rtl_tcp failure
|
# Release SDR device on rtl_tcp failure
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||||
|
|
||||||
|
# Wait for rtl_tcp to start outside lock
|
||||||
|
if rtl_tcp_just_started:
|
||||||
|
time.sleep(3)
|
||||||
|
logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}")
|
||||||
|
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'})
|
||||||
|
|
||||||
# Build rtlamr command
|
# Build rtlamr command
|
||||||
cmd = [
|
cmd = [
|
||||||
'rtlamr',
|
'rtlamr',
|
||||||
@@ -238,7 +251,7 @@ def start_rtlamr() -> Response:
|
|||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -249,38 +262,47 @@ def start_rtlamr() -> Response:
|
|||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||||
def stop_rtlamr() -> Response:
|
def stop_rtlamr() -> Response:
|
||||||
global rtl_tcp_process, rtlamr_active_device
|
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:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
app_module.rtlamr_process.terminate()
|
rtlamr_proc = app_module.rtlamr_process
|
||||||
try:
|
|
||||||
app_module.rtlamr_process.wait(timeout=2)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
app_module.rtlamr_process.kill()
|
|
||||||
app_module.rtlamr_process = None
|
app_module.rtlamr_process = None
|
||||||
|
|
||||||
|
if rtlamr_proc:
|
||||||
|
rtlamr_proc.terminate()
|
||||||
|
try:
|
||||||
|
rtlamr_proc.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rtlamr_proc.kill()
|
||||||
|
|
||||||
# Also stop rtl_tcp
|
# Also stop rtl_tcp
|
||||||
|
tcp_proc = None
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if rtl_tcp_process:
|
if rtl_tcp_process:
|
||||||
rtl_tcp_process.terminate()
|
tcp_proc = rtl_tcp_process
|
||||||
try:
|
|
||||||
rtl_tcp_process.wait(timeout=2)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
rtl_tcp_process.kill()
|
|
||||||
rtl_tcp_process = None
|
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
|
# Release device from registry
|
||||||
if rtlamr_active_device is not None:
|
if rtlamr_active_device is not None:
|
||||||
app_module.release_sdr_device(rtlamr_active_device)
|
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||||
rtlamr_active_device = None
|
rtlamr_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
@@ -288,26 +310,19 @@ def stop_rtlamr() -> Response:
|
|||||||
|
|
||||||
@rtlamr_bp.route('/stream_rtlamr')
|
@rtlamr_bp.route('/stream_rtlamr')
|
||||||
def stream_rtlamr() -> Response:
|
def stream_rtlamr() -> Response:
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('rtlamr', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
source_queue=app_module.rtlamr_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='rtlamr',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('rtlamr', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
+53
-12
@@ -28,6 +28,17 @@ from utils.validation import validate_latitude, validate_longitude, validate_hou
|
|||||||
|
|
||||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||||
|
|
||||||
|
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
|
||||||
|
_cached_timescale = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_timescale():
|
||||||
|
global _cached_timescale
|
||||||
|
if _cached_timescale is None:
|
||||||
|
from skyfield.api import load
|
||||||
|
_cached_timescale = load.timescale()
|
||||||
|
return _cached_timescale
|
||||||
|
|
||||||
# Maximum response size for external requests (1MB)
|
# Maximum response size for external requests (1MB)
|
||||||
MAX_RESPONSE_SIZE = 1024 * 1024
|
MAX_RESPONSE_SIZE = 1024 * 1024
|
||||||
|
|
||||||
@@ -166,9 +177,11 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
|||||||
@satellite_bp.route('/dashboard')
|
@satellite_bp.route('/dashboard')
|
||||||
def satellite_dashboard():
|
def satellite_dashboard():
|
||||||
"""Popout satellite tracking dashboard."""
|
"""Popout satellite tracking dashboard."""
|
||||||
|
embedded = request.args.get('embedded', 'false') == 'true'
|
||||||
return render_template(
|
return render_template(
|
||||||
'satellite_dashboard.html',
|
'satellite_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
embedded=embedded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,7 +189,7 @@ def satellite_dashboard():
|
|||||||
def predict_passes():
|
def predict_passes():
|
||||||
"""Calculate satellite passes using skyfield."""
|
"""Calculate satellite passes using skyfield."""
|
||||||
try:
|
try:
|
||||||
from skyfield.api import load, wgs84, EarthSatellite
|
from skyfield.api import wgs84, EarthSatellite
|
||||||
from skyfield.almanac import find_discrete
|
from skyfield.almanac import find_discrete
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -217,7 +230,7 @@ def predict_passes():
|
|||||||
}
|
}
|
||||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||||
|
|
||||||
ts = load.timescale()
|
ts = _get_timescale()
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
|
||||||
t0 = ts.now()
|
t0 = ts.now()
|
||||||
@@ -330,7 +343,7 @@ def predict_passes():
|
|||||||
def get_satellite_position():
|
def get_satellite_position():
|
||||||
"""Get real-time positions of satellites."""
|
"""Get real-time positions of satellites."""
|
||||||
try:
|
try:
|
||||||
from skyfield.api import load, wgs84, EarthSatellite
|
from skyfield.api import wgs84, EarthSatellite
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||||
|
|
||||||
@@ -359,7 +372,7 @@ def get_satellite_position():
|
|||||||
else:
|
else:
|
||||||
satellites.append(sat)
|
satellites.append(sat)
|
||||||
|
|
||||||
ts = load.timescale()
|
ts = _get_timescale()
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
now = ts.now()
|
now = ts.now()
|
||||||
now_dt = now.utc_datetime()
|
now_dt = now.utc_datetime()
|
||||||
@@ -466,7 +479,8 @@ def refresh_tle_data() -> list:
|
|||||||
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
||||||
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
||||||
'METEOR-M 2': 'METEOR-M2',
|
'METEOR-M 2': 'METEOR-M2',
|
||||||
'METEOR-M2 3': 'METEOR-M2-3'
|
'METEOR-M2 3': 'METEOR-M2-3',
|
||||||
|
'METEOR-M2 4': 'METEOR-M2-4'
|
||||||
}
|
}
|
||||||
|
|
||||||
updated = []
|
updated = []
|
||||||
@@ -588,14 +602,14 @@ def list_tracked_satellites():
|
|||||||
def add_tracked_satellites_endpoint():
|
def add_tracked_satellites_endpoint():
|
||||||
"""Add one or more tracked satellites."""
|
"""Add one or more tracked satellites."""
|
||||||
global _tle_cache
|
global _tle_cache
|
||||||
data = request.json
|
data = request.get_json(silent=True)
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
# Accept a single satellite dict or a list
|
# Accept a single satellite dict or a list
|
||||||
sat_list = data if isinstance(data, list) else [data]
|
sat_list = data if isinstance(data, list) else [data]
|
||||||
|
|
||||||
added = 0
|
normalized: list[dict] = []
|
||||||
for sat in sat_list:
|
for sat in sat_list:
|
||||||
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||||
name = sat.get('name', '')
|
name = sat.get('name', '')
|
||||||
@@ -605,19 +619,46 @@ def add_tracked_satellites_endpoint():
|
|||||||
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||||
enabled = sat.get('enabled', True)
|
enabled = sat.get('enabled', True)
|
||||||
|
|
||||||
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
|
normalized.append({
|
||||||
added += 1
|
'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
|
# Also inject into TLE cache if we have TLE data
|
||||||
if tle1 and tle2:
|
if tle1 and tle2:
|
||||||
cache_key = name.replace(' ', '-').upper()
|
cache_key = name.replace(' ', '-').upper()
|
||||||
_tle_cache[cache_key] = (name, tle1, tle2)
|
_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',
|
'status': 'success',
|
||||||
'added': added,
|
'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'])
|
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
|
||||||
|
|||||||
+99
-35
@@ -3,12 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
@@ -27,6 +28,41 @@ sensor_bp = Blueprint('sensor', __name__)
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sensor_active_device: int | None = None
|
sensor_active_device: int | None = None
|
||||||
|
sensor_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||||
|
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||||
|
_MAX_RSSI_HISTORY = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
||||||
|
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
||||||
|
points = max(32, min(points, 512))
|
||||||
|
|
||||||
|
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
||||||
|
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
||||||
|
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
||||||
|
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
||||||
|
|
||||||
|
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
||||||
|
cycles = 3.0 + (snr_norm * 8.0)
|
||||||
|
harmonic = 0.25 + (0.35 * snr_norm)
|
||||||
|
hiss = 0.08 + (0.18 * noise_norm)
|
||||||
|
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
||||||
|
|
||||||
|
waveform: list[int] = []
|
||||||
|
for i in range(points):
|
||||||
|
t = i / (points - 1)
|
||||||
|
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
||||||
|
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
||||||
|
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
||||||
|
|
||||||
|
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
||||||
|
sample /= (1.0 + harmonic + hiss)
|
||||||
|
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
||||||
|
waveform.append(max(-127, min(127, packed)))
|
||||||
|
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
@@ -45,19 +81,38 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
|
# Track RSSI history per device
|
||||||
|
_model = data.get('model', '')
|
||||||
|
_dev_id = data.get('id', '')
|
||||||
|
_rssi_val = data.get('rssi')
|
||||||
|
if _rssi_val is not None and _model:
|
||||||
|
_hist_key = f"{_model}_{_dev_id}"
|
||||||
|
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||||
|
hist.append((time.time(), float(_rssi_val)))
|
||||||
|
if len(hist) > _MAX_RSSI_HISTORY:
|
||||||
|
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||||
|
|
||||||
# Push scope event when signal level data is present
|
# Push scope event when signal level data is present
|
||||||
rssi = data.get('rssi')
|
rssi = data.get('rssi')
|
||||||
snr = data.get('snr')
|
snr = data.get('snr')
|
||||||
noise = data.get('noise')
|
noise = data.get('noise')
|
||||||
if rssi is not None or snr is not None:
|
if rssi is not None or snr is not None:
|
||||||
try:
|
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({
|
app_module.sensor_queue.put_nowait({
|
||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rssi': rssi if rssi is not None else 0,
|
'rssi': rssi_value,
|
||||||
'snr': snr if snr is not None else 0,
|
'snr': snr_value,
|
||||||
'noise': noise if noise is not None else 0,
|
'noise': noise_value,
|
||||||
|
'waveform': _build_scope_waveform(
|
||||||
|
rssi=rssi_value,
|
||||||
|
snr=snr_value,
|
||||||
|
noise=noise_value,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except (TypeError, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
@@ -75,7 +130,7 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global sensor_active_device
|
global sensor_active_device, sensor_active_sdr_type
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
@@ -91,8 +146,9 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
|
sensor_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/sensor/status')
|
@sensor_bp.route('/sensor/status')
|
||||||
@@ -105,7 +161,7 @@ def sensor_status() -> Response:
|
|||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
global sensor_active_device
|
global sensor_active_device, sensor_active_sdr_type
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
@@ -126,10 +182,13 @@ def start_sensor() -> Response:
|
|||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Get SDR type early so we can pass it to claim/release
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
# Claim local device if not using remote rtl_tcp
|
# Claim local device if not using remote rtl_tcp
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'sensor')
|
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -137,6 +196,7 @@ def start_sensor() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
sensor_active_device = device_int
|
sensor_active_device = device_int
|
||||||
|
sensor_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.sensor_queue.empty():
|
while not app_module.sensor_queue.empty():
|
||||||
@@ -145,8 +205,7 @@ def start_sensor() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get SDR type and build command via abstraction layer
|
# Build command via SDR abstraction layer
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -223,20 +282,22 @@ def start_sensor() -> Response:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
|
sensor_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
|
sensor_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
def stop_sensor() -> Response:
|
def stop_sensor() -> Response:
|
||||||
global sensor_active_device
|
global sensor_active_device, sensor_active_sdr_type
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
@@ -249,8 +310,9 @@ def stop_sensor() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
|
sensor_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -259,27 +321,29 @@ def stop_sensor() -> Response:
|
|||||||
|
|
||||||
@sensor_bp.route('/stream_sensor')
|
@sensor_bp.route('/stream_sensor')
|
||||||
def stream_sensor() -> Response:
|
def stream_sensor() -> Response:
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sensor', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
source_queue=app_module.sensor_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='sensor',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('sensor', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@sensor_bp.route('/sensor/rssi_history')
|
||||||
|
def get_rssi_history() -> Response:
|
||||||
|
"""Return RSSI history for all tracked sensor devices."""
|
||||||
|
result = {}
|
||||||
|
for key, entries in sensor_rssi_history.items():
|
||||||
|
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||||
|
return 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
@@ -27,11 +28,36 @@ logger = get_logger('intercept.sstv')
|
|||||||
|
|
||||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||||
|
|
||||||
|
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
||||||
|
# can type nearby values and still land on the canonical center frequency.
|
||||||
|
ISS_SSTV_MODULATION = 'fm'
|
||||||
|
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
||||||
|
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||||
|
|
||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Caching — ISS position (external API) and schedule (skyfield computation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_iss_position_cache: dict | None = None
|
||||||
|
_iss_position_cache_time: float = 0
|
||||||
|
_iss_position_lock = threading.Lock()
|
||||||
|
ISS_POSITION_CACHE_TTL = 10 # seconds
|
||||||
|
|
||||||
|
_iss_schedule_cache: dict | None = None
|
||||||
|
_iss_schedule_cache_time: float = 0
|
||||||
|
_iss_schedule_cache_key: str | None = None
|
||||||
|
_iss_schedule_lock = threading.Lock()
|
||||||
|
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
|
||||||
|
|
||||||
|
# Reusable skyfield timescale (expensive to create)
|
||||||
|
_timescale = None
|
||||||
|
_timescale_lock = threading.Lock()
|
||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sstv_active_device: int | None = None
|
sstv_active_device: int | None = None
|
||||||
|
sstv_active_sdr_type: str = 'rtlsdr'
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(data: dict) -> None:
|
def _progress_callback(data: dict) -> None:
|
||||||
@@ -46,6 +72,14 @@ def _progress_callback(data: dict) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
||||||
|
"""Snap near-match user input to a supported ISS SSTV center frequency."""
|
||||||
|
for supported in ISS_SSTV_FREQUENCIES:
|
||||||
|
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
|
||||||
|
return supported
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/status')
|
@sstv_bp.route('/status')
|
||||||
def get_status():
|
def get_status():
|
||||||
"""
|
"""
|
||||||
@@ -62,6 +96,7 @@ def get_status():
|
|||||||
'decoder': decoder.decoder_available,
|
'decoder': decoder.decoder_available,
|
||||||
'running': decoder.is_running,
|
'running': decoder.is_running,
|
||||||
'iss_frequency': ISS_SSTV_FREQ,
|
'iss_frequency': ISS_SSTV_FREQ,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'image_count': len(decoder.get_images()),
|
'image_count': len(decoder.get_images()),
|
||||||
'doppler_enabled': decoder.doppler_enabled,
|
'doppler_enabled': decoder.doppler_enabled,
|
||||||
}
|
}
|
||||||
@@ -82,6 +117,7 @@ def start_decoder():
|
|||||||
JSON body (optional):
|
JSON body (optional):
|
||||||
{
|
{
|
||||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||||
|
"modulation": "fm", // ISS mode is FM-only
|
||||||
"device": 0, // RTL-SDR device index
|
"device": 0, // RTL-SDR device index
|
||||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||||
@@ -106,6 +142,7 @@ def start_decoder():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'frequency': ISS_SSTV_FREQ,
|
'frequency': ISS_SSTV_FREQ,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -118,19 +155,38 @@ def start_decoder():
|
|||||||
|
|
||||||
# Get parameters
|
# Get parameters
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
|
if sdr_type_str != 'rtlsdr':
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||||
|
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
latitude = data.get('latitude')
|
latitude = data.get('latitude')
|
||||||
longitude = data.get('longitude')
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
|
# Validate modulation (ISS mode is FM-only)
|
||||||
|
if modulation != ISS_SSTV_MODULATION:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Validate frequency
|
# Validate frequency
|
||||||
try:
|
try:
|
||||||
frequency = float(frequency)
|
frequency = float(frequency)
|
||||||
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({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Frequency must be between 100-500 MHz'
|
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||||
}), 400
|
}), 400
|
||||||
|
frequency = normalized_frequency
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -162,9 +218,9 @@ def start_decoder():
|
|||||||
longitude = None
|
longitude = None
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
global sstv_active_device
|
global sstv_active_device, sstv_active_sdr_type
|
||||||
device_int = int(device_index)
|
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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -178,15 +234,18 @@ def start_decoder():
|
|||||||
frequency=frequency,
|
frequency=frequency,
|
||||||
device_index=device_index,
|
device_index=device_index,
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude
|
longitude=longitude,
|
||||||
|
modulation=ISS_SSTV_MODULATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
sstv_active_device = device_int
|
sstv_active_device = device_int
|
||||||
|
sstv_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'device': device_index,
|
'device': device_index,
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
}
|
}
|
||||||
@@ -198,7 +257,7 @@ def start_decoder():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
else:
|
else:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder'
|
'message': 'Failed to start decoder'
|
||||||
@@ -213,13 +272,13 @@ def stop_decoder():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON confirmation.
|
JSON confirmation.
|
||||||
"""
|
"""
|
||||||
global sstv_active_device
|
global sstv_active_device, sstv_active_sdr_type
|
||||||
decoder = get_sstv_decoder()
|
decoder = get_sstv_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if sstv_active_device is not None:
|
if sstv_active_device is not None:
|
||||||
app_module.release_sdr_device(sstv_active_device)
|
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
|
||||||
sstv_active_device = None
|
sstv_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
@@ -392,38 +451,42 @@ def stream_progress():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sstv', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
progress = _sstv_queue.get(timeout=1)
|
source_queue=_sstv_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='sstv',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('sstv', progress, progress.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(progress)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _get_timescale():
|
||||||
|
"""Return a cached skyfield timescale (expensive to create)."""
|
||||||
|
global _timescale
|
||||||
|
with _timescale_lock:
|
||||||
|
if _timescale is None:
|
||||||
|
from skyfield.api import load
|
||||||
|
_timescale = load.timescale()
|
||||||
|
return _timescale
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/iss-schedule')
|
@sstv_bp.route('/iss-schedule')
|
||||||
def iss_schedule():
|
def iss_schedule():
|
||||||
"""
|
"""
|
||||||
Get ISS pass schedule for SSTV reception.
|
Get ISS pass schedule for SSTV reception.
|
||||||
|
|
||||||
Calculates ISS passes directly using skyfield.
|
Calculates ISS passes directly using skyfield.
|
||||||
|
Results are cached for 15 minutes per rounded location.
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
latitude: Observer latitude (required)
|
latitude: Observer latitude (required)
|
||||||
@@ -433,6 +496,8 @@ def iss_schedule():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON with ISS pass schedule.
|
JSON with ISS pass schedule.
|
||||||
"""
|
"""
|
||||||
|
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
|
||||||
|
|
||||||
lat = request.args.get('latitude', type=float)
|
lat = request.args.get('latitude', type=float)
|
||||||
lon = request.args.get('longitude', type=float)
|
lon = request.args.get('longitude', type=float)
|
||||||
hours = request.args.get('hours', 48, type=int)
|
hours = request.args.get('hours', 48, type=int)
|
||||||
@@ -443,8 +508,18 @@ def iss_schedule():
|
|||||||
'message': 'latitude and longitude parameters required'
|
'message': 'latitude and longitude parameters required'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
|
||||||
|
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
|
||||||
|
|
||||||
|
with _iss_schedule_lock:
|
||||||
|
now = time.time()
|
||||||
|
if (_iss_schedule_cache is not None
|
||||||
|
and cache_key == _iss_schedule_cache_key
|
||||||
|
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
|
||||||
|
return jsonify(_iss_schedule_cache)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from skyfield.api import load, wgs84, EarthSatellite
|
from skyfield.api import wgs84, EarthSatellite
|
||||||
from skyfield.almanac import find_discrete
|
from skyfield.almanac import find_discrete
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
@@ -457,7 +532,7 @@ def iss_schedule():
|
|||||||
'message': 'ISS TLE data not available'
|
'message': 'ISS TLE data not available'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
ts = load.timescale()
|
ts = _get_timescale()
|
||||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
|
||||||
@@ -520,13 +595,21 @@ def iss_schedule():
|
|||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
return jsonify({
|
result = {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'passes': passes,
|
'passes': passes,
|
||||||
'count': len(passes),
|
'count': len(passes),
|
||||||
'sstv_frequency': ISS_SSTV_FREQ,
|
'sstv_frequency': ISS_SSTV_FREQ,
|
||||||
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
||||||
})
|
}
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
with _iss_schedule_lock:
|
||||||
|
_iss_schedule_cache = result
|
||||||
|
_iss_schedule_cache_time = time.time()
|
||||||
|
_iss_schedule_cache_key = cache_key
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -542,13 +625,65 @@ def iss_schedule():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_iss_position() -> dict | None:
|
||||||
|
"""Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache."""
|
||||||
|
global _iss_position_cache, _iss_position_cache_time
|
||||||
|
|
||||||
|
with _iss_position_lock:
|
||||||
|
now = time.time()
|
||||||
|
if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL:
|
||||||
|
return _iss_position_cache
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
cached = None
|
||||||
|
|
||||||
|
# Try primary API: Where The ISS At
|
||||||
|
try:
|
||||||
|
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
cached = {
|
||||||
|
'lat': float(data['latitude']),
|
||||||
|
'lon': float(data['longitude']),
|
||||||
|
'altitude': float(data.get('altitude', 420)),
|
||||||
|
'source': 'wheretheiss',
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Where The ISS At API failed: {e}")
|
||||||
|
|
||||||
|
# Try fallback API: Open Notify
|
||||||
|
if cached is None:
|
||||||
|
try:
|
||||||
|
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('message') == 'success':
|
||||||
|
cached = {
|
||||||
|
'lat': float(data['iss_position']['latitude']),
|
||||||
|
'lon': float(data['iss_position']['longitude']),
|
||||||
|
'altitude': 420,
|
||||||
|
'source': 'open-notify',
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Open Notify API failed: {e}")
|
||||||
|
|
||||||
|
if cached is not None:
|
||||||
|
with _iss_position_lock:
|
||||||
|
_iss_position_cache = cached
|
||||||
|
_iss_position_cache_time = time.time()
|
||||||
|
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/iss-position')
|
@sstv_bp.route('/iss-position')
|
||||||
def iss_position():
|
def iss_position():
|
||||||
"""
|
"""
|
||||||
Get current ISS position from real-time API.
|
Get current ISS position from real-time API.
|
||||||
|
|
||||||
Uses the Open Notify API for accurate real-time position,
|
Uses the "Where The ISS At" API for accurate real-time position,
|
||||||
with fallback to "Where The ISS At" API.
|
with fallback to Open Notify API. Raw position is cached for 10 seconds;
|
||||||
|
observer-relative data (elevation/azimuth) is computed per-request.
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
latitude: Observer latitude (optional, for elevation calc)
|
latitude: Observer latitude (optional, for elevation calc)
|
||||||
@@ -557,68 +692,32 @@ def iss_position():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON with ISS current position.
|
JSON with ISS current position.
|
||||||
"""
|
"""
|
||||||
import requests
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
observer_lat = request.args.get('latitude', type=float)
|
observer_lat = request.args.get('latitude', type=float)
|
||||||
observer_lon = request.args.get('longitude', type=float)
|
observer_lon = request.args.get('longitude', type=float)
|
||||||
|
|
||||||
# Try primary API: Where The ISS At
|
pos = _fetch_iss_position()
|
||||||
try:
|
if pos is None:
|
||||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
return jsonify({
|
||||||
if response.status_code == 200:
|
'status': 'error',
|
||||||
data = response.json()
|
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||||
iss_lat = float(data['latitude'])
|
}), 503
|
||||||
iss_lon = float(data['longitude'])
|
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'lat': iss_lat,
|
'lat': pos['lat'],
|
||||||
'lon': iss_lon,
|
'lon': pos['lon'],
|
||||||
'altitude': float(data.get('altitude', 420)),
|
'altitude': pos['altitude'],
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
'source': 'wheretheiss'
|
'source': pos['source'],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Calculate observer-relative data if location provided
|
# Calculate observer-relative data if location provided
|
||||||
if observer_lat is not None and observer_lon is not None:
|
if observer_lat is not None and observer_lon is not None:
|
||||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Where The ISS At API failed: {e}")
|
|
||||||
|
|
||||||
# Try fallback API: Open Notify
|
|
||||||
try:
|
|
||||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
if data.get('message') == 'success':
|
|
||||||
iss_lat = float(data['iss_position']['latitude'])
|
|
||||||
iss_lon = float(data['iss_position']['longitude'])
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'status': 'ok',
|
|
||||||
'lat': iss_lat,
|
|
||||||
'lon': iss_lon,
|
|
||||||
'altitude': 420, # Approximate ISS altitude in km
|
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
|
||||||
'source': 'open-notify'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate observer-relative data if location provided
|
|
||||||
if observer_lat is not None and observer_lon is not None:
|
|
||||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
|
||||||
|
|
||||||
return jsonify(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Open Notify API failed: {e}")
|
|
||||||
|
|
||||||
# Both APIs failed
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
||||||
|
|||||||
+47
-21
@@ -13,8 +13,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
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.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
@@ -27,6 +28,10 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
|
|||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
_sstv_general_active_device: int | None = None
|
||||||
|
_sstv_general_active_sdr_type: str = 'rtlsdr'
|
||||||
|
|
||||||
# Predefined SSTV frequencies
|
# Predefined SSTV frequencies
|
||||||
SSTV_FREQUENCIES = [
|
SSTV_FREQUENCIES = [
|
||||||
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
||||||
@@ -40,7 +45,7 @@ SSTV_FREQUENCIES = [
|
|||||||
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||||
{'band': '2 m', 'frequency': 145.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'},
|
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -115,6 +120,14 @@ def start_decoder():
|
|||||||
break
|
break
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
|
if sdr_type_str != 'rtlsdr':
|
||||||
|
return 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')
|
frequency = data.get('frequency')
|
||||||
modulation = data.get('modulation')
|
modulation = data.get('modulation')
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
@@ -150,6 +163,17 @@ def start_decoder():
|
|||||||
'message': 'Modulation must be fm, usb, or lsb',
|
'message': 'Modulation must be fm, usb, or lsb',
|
||||||
}), 400
|
}), 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
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
success = decoder.start(
|
success = decoder.start(
|
||||||
@@ -159,6 +183,8 @@ def start_decoder():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
_sstv_general_active_device = device_int
|
||||||
|
_sstv_general_active_sdr_type = sdr_type_str
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
@@ -166,6 +192,7 @@ def start_decoder():
|
|||||||
'device': device_index,
|
'device': device_index,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder',
|
'message': 'Failed to start decoder',
|
||||||
@@ -175,8 +202,14 @@ def start_decoder():
|
|||||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoder():
|
def stop_decoder():
|
||||||
"""Stop general SSTV decoder."""
|
"""Stop general SSTV decoder."""
|
||||||
|
global _sstv_general_active_device, _sstv_general_active_sdr_type
|
||||||
decoder = get_general_sstv_decoder()
|
decoder = get_general_sstv_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
|
if _sstv_general_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
|
||||||
|
_sstv_general_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -266,26 +299,19 @@ def delete_all_images():
|
|||||||
@sstv_general_bp.route('/stream')
|
@sstv_general_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""SSE stream of SSTV decode progress."""
|
"""SSE stream of SSTV decode progress."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sstv_general', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
progress = _sstv_general_queue.get(timeout=1)
|
source_queue=_sstv_general_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='sstv_general',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('sstv_general', progress, progress.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(progress)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream
|
from utils.sse import sse_stream
|
||||||
from utils.subghz import get_subghz_manager
|
from utils.subghz import get_subghz_manager
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SUBGHZ_FREQ_MIN_MHZ,
|
SUBGHZ_FREQ_MIN_MHZ,
|
||||||
SUBGHZ_FREQ_MAX_MHZ,
|
SUBGHZ_FREQ_MAX_MHZ,
|
||||||
@@ -34,6 +35,10 @@ _subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
|||||||
|
|
||||||
def _event_callback(event: dict) -> None:
|
def _event_callback(event: dict) -> None:
|
||||||
"""Forward SubGhzManager events to the SSE queue."""
|
"""Forward SubGhzManager events to the SSE queue."""
|
||||||
|
try:
|
||||||
|
process_event('subghz', event, event.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
_subghz_queue.put_nowait(event)
|
_subghz_queue.put_nowait(event)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
|
|||||||
@@ -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,
|
ingest_wifi_dict,
|
||||||
)
|
)
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
# Import unified Bluetooth scanner helper for TSCM integration
|
# Import unified Bluetooth scanner helper for TSCM integration
|
||||||
try:
|
try:
|
||||||
@@ -629,24 +630,17 @@ def sweep_status():
|
|||||||
@tscm_bp.route('/sweep/stream')
|
@tscm_bp.route('/sweep/stream')
|
||||||
def sweep_stream():
|
def sweep_stream():
|
||||||
"""SSE stream for real-time sweep updates."""
|
"""SSE stream for real-time sweep updates."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
while True:
|
process_event('tscm', msg, msg.get('type'))
|
||||||
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"
|
|
||||||
|
|
||||||
return Response(
|
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',
|
mimetype='text/event-stream',
|
||||||
headers={
|
headers={
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
@@ -1351,7 +1345,7 @@ def _scan_rf_signals(
|
|||||||
sweep_ranges: list[dict] | None = None
|
sweep_ranges: list[dict] | None = None
|
||||||
) -> list[dict]:
|
) -> 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:
|
Scans common surveillance frequency bands:
|
||||||
- 88-108 MHz: FM broadcast (potential FM bugs)
|
- 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})")
|
logger.info(f"Starting RF scan (device={sdr_device})")
|
||||||
|
|
||||||
|
# Detect available SDR devices and sweep tools
|
||||||
rtl_power_path = shutil.which('rtl_power')
|
rtl_power_path = shutil.which('rtl_power')
|
||||||
if not rtl_power_path:
|
hackrf_sweep_path = shutil.which('hackrf_sweep')
|
||||||
logger.warning("rtl_power not found in PATH, RF scanning unavailable")
|
|
||||||
|
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', {
|
_emit_event('rf_status', {
|
||||||
'status': 'error',
|
'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
|
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)
|
# Define frequency bands to scan (in Hz)
|
||||||
# Format: (start_freq, end_freq, bin_size, description)
|
# Format: (start_freq, end_freq, bin_size, description)
|
||||||
scan_bands: list[tuple[int, int, int, str]] = []
|
scan_bands: list[tuple[int, int, int, str]] = []
|
||||||
@@ -1454,7 +1459,7 @@ def _scan_rf_signals(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Build device argument
|
# 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
|
# Scan each band and look for strong signals
|
||||||
for start_freq, end_freq, bin_size, band_name in scan_bands:
|
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)")
|
logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run rtl_power for a quick sweep of this band
|
# Build sweep command based on SDR type
|
||||||
cmd = [
|
if sdr_type == 'hackrf':
|
||||||
rtl_power_path,
|
cmd = [
|
||||||
'-f', f'{start_freq}:{end_freq}:{bin_size}',
|
sweep_tool_path,
|
||||||
'-g', '40', # Gain
|
'-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}',
|
||||||
'-i', '1', # Integration interval (1 second)
|
'-w', str(bin_size),
|
||||||
'-1', # Single shot mode
|
'-1', # Single sweep
|
||||||
'-c', '20%', # Crop 20% of edges
|
]
|
||||||
] + device_arg + [tmp_path]
|
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)}")
|
logger.debug(f"Running: {' '.join(cmd)}")
|
||||||
|
|
||||||
@@ -1484,9 +1501,14 @@ def _scan_rf_signals(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode != 0:
|
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:
|
if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0:
|
||||||
with open(tmp_path, 'r') as f:
|
with open(tmp_path, 'r') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
@@ -1494,13 +1516,12 @@ def _scan_rf_signals(
|
|||||||
if len(parts) >= 7:
|
if len(parts) >= 7:
|
||||||
try:
|
try:
|
||||||
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
|
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
|
||||||
hz_low = int(parts[2])
|
hz_low = int(parts[2].strip())
|
||||||
hz_high = int(parts[3])
|
hz_high = int(parts[3].strip())
|
||||||
hz_step = float(parts[4])
|
hz_step = float(parts[4].strip())
|
||||||
db_values = [float(x) for x in parts[6:] if x.strip()]
|
db_values = [float(x) for x in parts[6:] if x.strip()]
|
||||||
|
|
||||||
# Find peaks above noise floor
|
# 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
|
noise_floor = sum(db_values) / len(db_values) if db_values else -100
|
||||||
threshold = noise_floor + 6 # Signal must be 6dB above noise
|
threshold = noise_floor + 6 # Signal must be 6dB above noise
|
||||||
|
|
||||||
|
|||||||
+122
-52
@@ -2,30 +2,36 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.acars_translator import translate_message
|
||||||
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.constants import (
|
from utils.constants import (
|
||||||
|
PROCESS_START_WAIT,
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
PROCESS_START_WAIT,
|
|
||||||
)
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.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')
|
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
||||||
|
|
||||||
@@ -44,6 +50,7 @@ vdl2_last_message_time = None
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
vdl2_active_device: int | None = None
|
vdl2_active_device: int | None = None
|
||||||
|
vdl2_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def find_dumpvdl2():
|
def find_dumpvdl2():
|
||||||
@@ -51,15 +58,20 @@ def find_dumpvdl2():
|
|||||||
return shutil.which('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."""
|
"""Stream dumpvdl2 JSON output to queue."""
|
||||||
global vdl2_message_count, vdl2_last_message_time
|
global vdl2_message_count, vdl2_last_message_time
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
for line in iter(process.stdout.readline, b''):
|
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||||
line = line.decode('utf-8', errors='replace').strip()
|
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:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -70,12 +82,33 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
|||||||
data['type'] = 'vdl2'
|
data['type'] = 'vdl2'
|
||||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
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
|
# Update stats
|
||||||
vdl2_message_count += 1
|
vdl2_message_count += 1
|
||||||
vdl2_last_message_time = time.time()
|
vdl2_last_message_time = time.time()
|
||||||
|
|
||||||
app_module.vdl2_queue.put(data)
|
app_module.vdl2_queue.put(data)
|
||||||
|
|
||||||
|
# Feed flight correlator
|
||||||
|
try:
|
||||||
|
get_flight_correlator().add_vdl2_message(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -94,7 +127,7 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
|||||||
logger.error(f"VDL2 stream error: {e}")
|
logger.error(f"VDL2 stream error: {e}")
|
||||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global vdl2_active_device
|
global vdl2_active_device, vdl2_active_sdr_type
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
@@ -110,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
|||||||
app_module.vdl2_process = None
|
app_module.vdl2_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/tools')
|
@vdl2_bp.route('/tools')
|
||||||
@@ -143,7 +177,7 @@ def vdl2_status() -> Response:
|
|||||||
@vdl2_bp.route('/start', methods=['POST'])
|
@vdl2_bp.route('/start', methods=['POST'])
|
||||||
def start_vdl2() -> Response:
|
def start_vdl2() -> Response:
|
||||||
"""Start VDL2 decoder."""
|
"""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:
|
with app_module.vdl2_lock:
|
||||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
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:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -181,6 +222,7 @@ def start_vdl2() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
vdl2_active_device = device_int
|
vdl2_active_device = device_int
|
||||||
|
vdl2_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
# dumpvdl2 expects frequencies in Hz (integers)
|
# dumpvdl2 expects frequencies in Hz (integers)
|
||||||
@@ -199,13 +241,6 @@ def start_vdl2() -> Response:
|
|||||||
vdl2_message_count = 0
|
vdl2_message_count = 0
|
||||||
vdl2_last_message_time = None
|
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,)
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
# Build dumpvdl2 command
|
# Build dumpvdl2 command
|
||||||
@@ -236,12 +271,28 @@ def start_vdl2() -> Response:
|
|||||||
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
is_text_mode = False
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
# On macOS, use pty to avoid stdout buffering issues
|
||||||
stderr=subprocess.PIPE,
|
if platform.system() == 'Darwin':
|
||||||
start_new_session=True
|
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
|
# Wait briefly to check if process started
|
||||||
time.sleep(PROCESS_START_WAIT)
|
time.sleep(PROCESS_START_WAIT)
|
||||||
@@ -249,14 +300,17 @@ def start_vdl2() -> Response:
|
|||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died - release device
|
# Process died - release device
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
|
if stderr:
|
||||||
|
logger.error(f"dumpvdl2 stderr:\n{stderr}")
|
||||||
error_msg = 'dumpvdl2 failed to start'
|
error_msg = 'dumpvdl2 failed to start'
|
||||||
if stderr:
|
if stderr:
|
||||||
error_msg += f': {stderr[:200]}'
|
error_msg += f': {stderr[:500]}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
@@ -266,7 +320,7 @@ def start_vdl2() -> Response:
|
|||||||
# Start output streaming thread
|
# Start output streaming thread
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=stream_vdl2_output,
|
target=stream_vdl2_output,
|
||||||
args=(process,),
|
args=(process, is_text_mode),
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -281,8 +335,9 @@ def start_vdl2() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -290,7 +345,7 @@ def start_vdl2() -> Response:
|
|||||||
@vdl2_bp.route('/stop', methods=['POST'])
|
@vdl2_bp.route('/stop', methods=['POST'])
|
||||||
def stop_vdl2() -> Response:
|
def stop_vdl2() -> Response:
|
||||||
"""Stop VDL2 decoder."""
|
"""Stop VDL2 decoder."""
|
||||||
global vdl2_active_device
|
global vdl2_active_device, vdl2_active_sdr_type
|
||||||
|
|
||||||
with app_module.vdl2_lock:
|
with app_module.vdl2_lock:
|
||||||
if not app_module.vdl2_process:
|
if not app_module.vdl2_process:
|
||||||
@@ -311,8 +366,9 @@ def stop_vdl2() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -320,30 +376,44 @@ def stop_vdl2() -> Response:
|
|||||||
@vdl2_bp.route('/stream')
|
@vdl2_bp.route('/stream')
|
||||||
def stream_vdl2() -> Response:
|
def stream_vdl2() -> Response:
|
||||||
"""SSE stream for VDL2 messages."""
|
"""SSE stream for VDL2 messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('vdl2', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.vdl2_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.vdl2_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='vdl2',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('vdl2', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
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')
|
@vdl2_bp.route('/frequencies')
|
||||||
def get_frequencies() -> Response:
|
def get_frequencies() -> Response:
|
||||||
"""Get default VDL2 frequencies."""
|
"""Get default VDL2 frequencies."""
|
||||||
|
|||||||
+519
-58
@@ -1,12 +1,18 @@
|
|||||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import queue
|
import queue
|
||||||
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -17,18 +23,37 @@ except ImportError:
|
|||||||
Sock = None
|
Sock = None
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
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.waterfall_fft import (
|
from utils.waterfall_fft import (
|
||||||
build_binary_frame,
|
build_binary_frame,
|
||||||
compute_power_spectrum,
|
compute_power_spectrum,
|
||||||
cu8_to_complex,
|
cu8_to_complex,
|
||||||
quantize_to_uint8,
|
quantize_to_uint8,
|
||||||
)
|
)
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
|
||||||
|
|
||||||
logger = get_logger('intercept.waterfall_ws')
|
logger = get_logger('intercept.waterfall_ws')
|
||||||
|
|
||||||
|
AUDIO_SAMPLE_RATE = 48000
|
||||||
|
_shared_state_lock = threading.Lock()
|
||||||
|
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=20)
|
||||||
|
_shared_state: dict[str, Any] = {
|
||||||
|
'running': False,
|
||||||
|
'device': None,
|
||||||
|
'center_mhz': 0.0,
|
||||||
|
'span_mhz': 0.0,
|
||||||
|
'sample_rate': 0,
|
||||||
|
'monitor_enabled': False,
|
||||||
|
'monitor_freq_mhz': 0.0,
|
||||||
|
'monitor_modulation': 'wfm',
|
||||||
|
'monitor_squelch': 0,
|
||||||
|
}
|
||||||
|
# Generation counter to prevent stale WebSocket handlers from clobbering
|
||||||
|
# shared state set by a newer handler (e.g. old handler's finally block
|
||||||
|
# running after a new connection has already started capture).
|
||||||
|
_capture_generation: int = 0
|
||||||
|
|
||||||
# Maximum bandwidth per SDR type (Hz)
|
# Maximum bandwidth per SDR type (Hz)
|
||||||
MAX_BANDWIDTH = {
|
MAX_BANDWIDTH = {
|
||||||
SDRType.RTL_SDR: 2400000,
|
SDRType.RTL_SDR: 2400000,
|
||||||
@@ -39,6 +64,261 @@ MAX_BANDWIDTH = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_shared_audio_queue() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_shared_audio_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _set_shared_capture_state(
|
||||||
|
*,
|
||||||
|
running: bool,
|
||||||
|
device: int | None = None,
|
||||||
|
center_mhz: float | None = None,
|
||||||
|
span_mhz: float | None = None,
|
||||||
|
sample_rate: int | None = None,
|
||||||
|
generation: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Update shared capture state.
|
||||||
|
|
||||||
|
Returns the current generation counter. When *running* is True and
|
||||||
|
*generation* is None the counter is bumped; callers should capture
|
||||||
|
the returned value and pass it back when setting running=False so
|
||||||
|
that stale handlers cannot clobber a newer session.
|
||||||
|
"""
|
||||||
|
global _capture_generation
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not running and generation is not None:
|
||||||
|
# Only allow the matching generation to clear the state.
|
||||||
|
if generation != _capture_generation:
|
||||||
|
return _capture_generation
|
||||||
|
if running and generation is None:
|
||||||
|
_capture_generation += 1
|
||||||
|
_shared_state['running'] = bool(running)
|
||||||
|
_shared_state['device'] = device if running else None
|
||||||
|
if center_mhz is not None:
|
||||||
|
_shared_state['center_mhz'] = float(center_mhz)
|
||||||
|
if span_mhz is not None:
|
||||||
|
_shared_state['span_mhz'] = float(span_mhz)
|
||||||
|
if sample_rate is not None:
|
||||||
|
_shared_state['sample_rate'] = int(sample_rate)
|
||||||
|
if not running:
|
||||||
|
_shared_state['monitor_enabled'] = False
|
||||||
|
gen = _capture_generation
|
||||||
|
if not running:
|
||||||
|
_clear_shared_audio_queue()
|
||||||
|
return gen
|
||||||
|
|
||||||
|
|
||||||
|
def _set_shared_monitor(
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
frequency_mhz: float | None = None,
|
||||||
|
modulation: str | None = None,
|
||||||
|
squelch: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
was_enabled = False
|
||||||
|
freq_changed = False
|
||||||
|
with _shared_state_lock:
|
||||||
|
was_enabled = bool(_shared_state.get('monitor_enabled'))
|
||||||
|
_shared_state['monitor_enabled'] = bool(enabled)
|
||||||
|
if frequency_mhz is not None:
|
||||||
|
old_freq = float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0)
|
||||||
|
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||||
|
if abs(float(frequency_mhz) - old_freq) > 1e-6:
|
||||||
|
freq_changed = True
|
||||||
|
if modulation is not None:
|
||||||
|
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||||
|
if squelch is not None:
|
||||||
|
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||||
|
if (was_enabled and not enabled) or (enabled and freq_changed):
|
||||||
|
_clear_shared_audio_queue()
|
||||||
|
|
||||||
|
|
||||||
|
def get_shared_capture_status() -> dict[str, Any]:
|
||||||
|
with _shared_state_lock:
|
||||||
|
return {
|
||||||
|
'running': bool(_shared_state['running']),
|
||||||
|
'device': _shared_state['device'],
|
||||||
|
'center_mhz': float(_shared_state.get('center_mhz', 0.0) or 0.0),
|
||||||
|
'span_mhz': float(_shared_state.get('span_mhz', 0.0) or 0.0),
|
||||||
|
'sample_rate': int(_shared_state.get('sample_rate', 0) or 0),
|
||||||
|
'monitor_enabled': bool(_shared_state.get('monitor_enabled')),
|
||||||
|
'monitor_freq_mhz': float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0),
|
||||||
|
'monitor_modulation': str(_shared_state.get('monitor_modulation', 'wfm')),
|
||||||
|
'monitor_squelch': int(_shared_state.get('monitor_squelch', 0) or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_shared_monitor_from_capture(
|
||||||
|
*,
|
||||||
|
device: int,
|
||||||
|
frequency_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
squelch: int,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not _shared_state['running']:
|
||||||
|
return False, 'Waterfall IQ stream not active'
|
||||||
|
if _shared_state['device'] != device:
|
||||||
|
return False, 'Waterfall stream is using a different SDR device'
|
||||||
|
_shared_state['monitor_enabled'] = True
|
||||||
|
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||||
|
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||||
|
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||||
|
_clear_shared_audio_queue()
|
||||||
|
return True, 'started'
|
||||||
|
|
||||||
|
|
||||||
|
def stop_shared_monitor_from_capture() -> None:
|
||||||
|
_set_shared_monitor(enabled=False)
|
||||||
|
|
||||||
|
|
||||||
|
def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None:
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not _shared_state['running'] or not _shared_state['monitor_enabled']:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _shared_audio_queue.get(timeout=max(0.0, float(timeout)))
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_monitor_config() -> dict[str, Any] | None:
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not (_shared_state['running'] and _shared_state['monitor_enabled']):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'center_mhz': float(_shared_state['center_mhz']),
|
||||||
|
'monitor_freq_mhz': float(_shared_state['monitor_freq_mhz']),
|
||||||
|
'modulation': str(_shared_state['monitor_modulation']),
|
||||||
|
'squelch': int(_shared_state['monitor_squelch']),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _push_shared_audio_chunk(chunk: bytes) -> None:
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
if _shared_audio_queue.full():
|
||||||
|
with suppress(queue.Empty):
|
||||||
|
_shared_audio_queue.get_nowait()
|
||||||
|
with suppress(queue.Full):
|
||||||
|
_shared_audio_queue.put_nowait(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def _demodulate_monitor_audio(
|
||||||
|
samples: np.ndarray,
|
||||||
|
sample_rate: int,
|
||||||
|
center_mhz: float,
|
||||||
|
monitor_freq_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
squelch: int,
|
||||||
|
rotator_phase: float = 0.0,
|
||||||
|
) -> tuple[bytes | None, float]:
|
||||||
|
if samples.size < 32 or sample_rate <= 0:
|
||||||
|
return None, float(rotator_phase)
|
||||||
|
|
||||||
|
fs = float(sample_rate)
|
||||||
|
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
|
||||||
|
nyquist = fs * 0.5
|
||||||
|
if abs(freq_offset_hz) > nyquist * 0.98:
|
||||||
|
return None, float(rotator_phase)
|
||||||
|
|
||||||
|
phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
|
||||||
|
n = np.arange(samples.size, dtype=np.float64)
|
||||||
|
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
|
||||||
|
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
|
||||||
|
shifted = samples * rotator
|
||||||
|
|
||||||
|
mod = str(modulation or 'wfm').lower().strip()
|
||||||
|
target_bb = 220000.0 if mod == 'wfm' else 48000.0
|
||||||
|
pre_decim = max(1, int(fs // target_bb))
|
||||||
|
if pre_decim > 1:
|
||||||
|
usable = (shifted.size // pre_decim) * pre_decim
|
||||||
|
if usable < pre_decim:
|
||||||
|
return None, next_phase
|
||||||
|
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||||
|
fs1 = fs / pre_decim
|
||||||
|
if shifted.size < 16:
|
||||||
|
return None, next_phase
|
||||||
|
|
||||||
|
if mod in ('wfm', 'fm'):
|
||||||
|
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
|
||||||
|
elif mod == 'am':
|
||||||
|
envelope = np.abs(shifted).astype(np.float32)
|
||||||
|
audio = envelope - float(np.mean(envelope))
|
||||||
|
elif mod == 'usb':
|
||||||
|
audio = np.real(shifted).astype(np.float32)
|
||||||
|
elif mod == 'lsb':
|
||||||
|
audio = -np.real(shifted).astype(np.float32)
|
||||||
|
else:
|
||||||
|
audio = np.real(shifted).astype(np.float32)
|
||||||
|
|
||||||
|
if audio.size < 8:
|
||||||
|
return None, next_phase
|
||||||
|
|
||||||
|
audio = audio - float(np.mean(audio))
|
||||||
|
|
||||||
|
if mod in ('fm', 'am', 'usb', 'lsb'):
|
||||||
|
taps = int(max(1, min(31, fs1 / 12000.0)))
|
||||||
|
if taps > 1:
|
||||||
|
kernel = np.ones(taps, dtype=np.float32) / float(taps)
|
||||||
|
audio = np.convolve(audio, kernel, mode='same')
|
||||||
|
|
||||||
|
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
|
||||||
|
if out_len < 32:
|
||||||
|
return None, next_phase
|
||||||
|
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
|
||||||
|
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
|
||||||
|
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
||||||
|
|
||||||
|
rms = float(np.sqrt(np.mean(audio * audio) + 1e-12))
|
||||||
|
level = min(100.0, rms * 450.0)
|
||||||
|
if squelch > 0 and level < float(squelch):
|
||||||
|
audio.fill(0.0)
|
||||||
|
|
||||||
|
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
||||||
|
if peak > 0:
|
||||||
|
audio = audio * min(20.0, 0.85 / peak)
|
||||||
|
|
||||||
|
pcm = np.clip(audio, -1.0, 1.0)
|
||||||
|
return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
|
||||||
|
"""Parse center frequency from mixed legacy/new payload formats."""
|
||||||
|
if payload.get('center_freq_mhz') is not None:
|
||||||
|
return float(payload['center_freq_mhz'])
|
||||||
|
|
||||||
|
if payload.get('center_freq_hz') is not None:
|
||||||
|
return float(payload['center_freq_hz']) / 1e6
|
||||||
|
|
||||||
|
raw = float(payload.get('center_freq', 100.0))
|
||||||
|
# Backward compatibility: some clients still send center_freq in Hz.
|
||||||
|
if raw > 100000:
|
||||||
|
return raw / 1e6
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_span_mhz(payload: dict[str, Any]) -> float:
|
||||||
|
"""Parse display span in MHz from mixed payload formats."""
|
||||||
|
if payload.get('span_hz') is not None:
|
||||||
|
return float(payload['span_hz']) / 1e6
|
||||||
|
return float(payload.get('span_mhz', 2.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
|
||||||
|
"""Pick a valid hardware sample rate nearest the requested span."""
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||||
"""Convert client sdr_type string to SDRType enum."""
|
"""Convert client sdr_type string to SDRType enum."""
|
||||||
mapping = {
|
mapping = {
|
||||||
@@ -87,6 +367,12 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = None
|
reader_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
|
my_generation = None # tracks which capture generation this handler owns
|
||||||
|
capture_center_mhz = 0.0
|
||||||
|
capture_start_freq = 0.0
|
||||||
|
capture_end_freq = 0.0
|
||||||
|
capture_span_mhz = 0.0
|
||||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
send_queue = queue.Queue(maxsize=120)
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
@@ -105,7 +391,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = ws.receive(timeout=0.1)
|
msg = ws.receive(timeout=0.01)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = str(e).lower()
|
err = str(e).lower()
|
||||||
if "closed" in err:
|
if "closed" in err:
|
||||||
@@ -131,6 +417,10 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
cmd = data.get('cmd')
|
cmd = data.get('cmd')
|
||||||
|
|
||||||
if cmd == 'start':
|
if cmd == 'start':
|
||||||
|
shared_before = get_shared_capture_status()
|
||||||
|
keep_monitor_enabled = bool(shared_before.get('monitor_enabled'))
|
||||||
|
keep_monitor_modulation = str(shared_before.get('monitor_modulation', 'wfm'))
|
||||||
|
keep_monitor_squelch = int(shared_before.get('monitor_squelch', 0) or 0)
|
||||||
# Stop any existing capture
|
# Stop any existing capture
|
||||||
was_restarting = iq_process is not None
|
was_restarting = iq_process is not None
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
@@ -141,8 +431,11 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
|
my_generation = None
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
# Flush stale frames from previous capture
|
# Flush stale frames from previous capture
|
||||||
while not send_queue.empty():
|
while not send_queue.empty():
|
||||||
@@ -155,37 +448,78 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
center_freq = float(data.get('center_freq', 100.0))
|
try:
|
||||||
span_mhz = float(data.get('span_mhz', 2.0))
|
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||||
gain = data.get('gain')
|
requested_vfo_mhz = float(
|
||||||
if gain is not None:
|
data.get(
|
||||||
gain = float(gain)
|
'vfo_freq_mhz',
|
||||||
device_index = int(data.get('device', 0))
|
data.get('frequency_mhz', center_freq_mhz),
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
)
|
||||||
fft_size = int(data.get('fft_size', 1024))
|
)
|
||||||
fps = int(data.get('fps', 25))
|
span_mhz = _parse_span_mhz(data)
|
||||||
avg_count = int(data.get('avg_count', 4))
|
gain_raw = data.get('gain')
|
||||||
ppm = data.get('ppm')
|
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||||
if ppm is not None:
|
gain = None
|
||||||
ppm = int(ppm)
|
else:
|
||||||
bias_t = bool(data.get('bias_t', False))
|
gain = float(gain_raw)
|
||||||
|
device_index = int(data.get('device', 0))
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
fft_size = int(data.get('fft_size', 1024))
|
||||||
|
fps = int(data.get('fps', 25))
|
||||||
|
avg_count = int(data.get('avg_count', 4))
|
||||||
|
ppm = data.get('ppm')
|
||||||
|
if ppm is not None:
|
||||||
|
ppm = int(ppm)
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
db_min = data.get('db_min')
|
||||||
|
db_max = data.get('db_max')
|
||||||
|
if db_min is not None:
|
||||||
|
db_min = float(db_min)
|
||||||
|
if db_max is not None:
|
||||||
|
db_max = float(db_max)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid waterfall configuration: {exc}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Clamp FFT size to valid powers of 2
|
# Clamp and normalize runtime settings
|
||||||
fft_size = max(256, min(8192, fft_size))
|
fft_size = max(256, min(8192, fft_size))
|
||||||
|
fps = max(2, min(60, fps))
|
||||||
|
avg_count = max(1, min(32, avg_count))
|
||||||
|
if center_freq_mhz <= 0 or span_mhz <= 0:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'center_freq_mhz and span_mhz must be > 0',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Resolve SDR type and bandwidth
|
# Resolve SDR type and choose a valid sample rate
|
||||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
span_hz = int(span_mhz * 1e6)
|
caps = builder.get_capabilities()
|
||||||
sample_rate = min(span_hz, max_bw)
|
requested_span_hz = max(1000, int(span_mhz * 1e6))
|
||||||
|
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
|
||||||
|
|
||||||
# Compute effective frequency range
|
# Compute effective frequency range
|
||||||
effective_span_mhz = sample_rate / 1e6
|
effective_span_mhz = sample_rate / 1e6
|
||||||
start_freq = center_freq - effective_span_mhz / 2
|
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||||
end_freq = center_freq + effective_span_mhz / 2
|
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||||
|
target_vfo_mhz = requested_vfo_mhz
|
||||||
|
if not (start_freq <= target_vfo_mhz <= end_freq):
|
||||||
|
target_vfo_mhz = center_freq_mhz
|
||||||
|
|
||||||
# Claim the device
|
# Claim the device (retry when restarting to allow
|
||||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
# the kernel time to release the USB handle).
|
||||||
|
max_claim_attempts = 4 if was_restarting else 1
|
||||||
|
claim_err = None
|
||||||
|
for _claim_attempt in range(max_claim_attempts):
|
||||||
|
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str)
|
||||||
|
if not claim_err:
|
||||||
|
break
|
||||||
|
if _claim_attempt < max_claim_attempts - 1:
|
||||||
|
time.sleep(0.4)
|
||||||
if claim_err:
|
if claim_err:
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -194,41 +528,53 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
claimed_device = device_index
|
claimed_device = device_index
|
||||||
|
claimed_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Build I/Q capture command
|
# Build I/Q capture command
|
||||||
try:
|
try:
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
device = _build_dummy_device(device_index, sdr_type)
|
device = _build_dummy_device(device_index, sdr_type)
|
||||||
iq_cmd = builder.build_iq_capture_command(
|
iq_cmd = builder.build_iq_capture_command(
|
||||||
device=device,
|
device=device,
|
||||||
frequency_mhz=center_freq,
|
frequency_mhz=center_freq_mhz,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
gain=gain,
|
gain=gain,
|
||||||
ppm=ppm,
|
ppm=ppm,
|
||||||
bias_t=bias_t,
|
bias_t=bias_t,
|
||||||
)
|
)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
app_module.release_sdr_device(device_index)
|
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': str(e),
|
'message': str(e),
|
||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Pre-flight: check the capture binary exists
|
||||||
|
if not shutil.which(iq_cmd[0]):
|
||||||
|
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||||
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||||
max_attempts = 3 if was_restarting else 1
|
max_attempts = 3 if was_restarting else 1
|
||||||
try:
|
try:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting I/Q capture: {center_freq} MHz, "
|
f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, "
|
||||||
f"span={effective_span_mhz:.1f} MHz, "
|
f"span={effective_span_mhz:.1f} MHz, "
|
||||||
f"sr={sample_rate}, fft={fft_size}"
|
f"sr={sample_rate}, fft={fft_size}"
|
||||||
)
|
)
|
||||||
iq_process = subprocess.Popen(
|
iq_process = subprocess.Popen(
|
||||||
iq_cmd,
|
iq_cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=0,
|
bufsize=0,
|
||||||
)
|
)
|
||||||
register_process(iq_process)
|
register_process(iq_process)
|
||||||
@@ -236,17 +582,23 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
# Brief check that process started
|
# Brief check that process started
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
if iq_process.poll() is not None:
|
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)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
if attempt < max_attempts - 1:
|
if attempt < max_attempts - 1:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"I/Q process exited immediately, "
|
f"I/Q process exited immediately, "
|
||||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||||
|
+ (f" stderr: {stderr_out}" if stderr_out else "")
|
||||||
)
|
)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
continue
|
continue
|
||||||
|
detail = f": {stderr_out}" if stderr_out else ""
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"I/Q capture process exited immediately"
|
f"I/Q capture process exited immediately{detail}"
|
||||||
)
|
)
|
||||||
break # Process started successfully
|
break # Process started successfully
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -255,32 +607,62 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
safe_terminate(iq_process)
|
safe_terminate(iq_process)
|
||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
app_module.release_sdr_device(device_index)
|
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Failed to start I/Q capture: {e}',
|
'message': f'Failed to start I/Q capture: {e}',
|
||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
capture_center_mhz = center_freq_mhz
|
||||||
|
capture_start_freq = start_freq
|
||||||
|
capture_end_freq = end_freq
|
||||||
|
capture_span_mhz = effective_span_mhz
|
||||||
|
|
||||||
|
my_generation = _set_shared_capture_state(
|
||||||
|
running=True,
|
||||||
|
device=device_index,
|
||||||
|
center_mhz=center_freq_mhz,
|
||||||
|
span_mhz=effective_span_mhz,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
)
|
||||||
|
_set_shared_monitor(
|
||||||
|
enabled=keep_monitor_enabled,
|
||||||
|
frequency_mhz=target_vfo_mhz,
|
||||||
|
modulation=keep_monitor_modulation,
|
||||||
|
squelch=keep_monitor_squelch,
|
||||||
|
)
|
||||||
|
|
||||||
# Send started confirmation
|
# Send started confirmation
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
|
'center_mhz': center_freq_mhz,
|
||||||
'start_freq': start_freq,
|
'start_freq': start_freq,
|
||||||
'end_freq': end_freq,
|
'end_freq': end_freq,
|
||||||
'fft_size': fft_size,
|
'fft_size': fft_size,
|
||||||
'sample_rate': sample_rate,
|
'sample_rate': sample_rate,
|
||||||
|
'effective_span_mhz': effective_span_mhz,
|
||||||
|
'db_min': db_min,
|
||||||
|
'db_max': db_max,
|
||||||
|
'vfo_freq_mhz': target_vfo_mhz,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
def fft_reader(
|
def fft_reader(
|
||||||
proc, _send_q, stop_evt,
|
proc, _send_q, stop_evt,
|
||||||
_fft_size, _avg_count, _fps,
|
_fft_size, _avg_count, _fps, _sample_rate,
|
||||||
_start_freq, _end_freq,
|
_start_freq, _end_freq, _center_mhz,
|
||||||
|
_db_min=None, _db_max=None,
|
||||||
):
|
):
|
||||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||||
bytes_per_frame = _fft_size * _avg_count * 2
|
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
|
frame_interval = 1.0 / _fps
|
||||||
|
monitor_rotator_phase = 0.0
|
||||||
|
last_monitor_offset_hz = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not stop_evt.is_set():
|
while not stop_evt.is_set():
|
||||||
@@ -304,21 +686,51 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
# Process FFT pipeline
|
# Process FFT pipeline
|
||||||
samples = cu8_to_complex(raw)
|
samples = cu8_to_complex(raw)
|
||||||
|
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||||
power_db = compute_power_spectrum(
|
power_db = compute_power_spectrum(
|
||||||
samples,
|
fft_samples,
|
||||||
fft_size=_fft_size,
|
fft_size=_fft_size,
|
||||||
avg_count=_avg_count,
|
avg_count=_avg_count,
|
||||||
)
|
)
|
||||||
quantized = quantize_to_uint8(power_db)
|
quantized = quantize_to_uint8(
|
||||||
|
power_db,
|
||||||
|
db_min=_db_min,
|
||||||
|
db_max=_db_max,
|
||||||
|
)
|
||||||
frame = build_binary_frame(
|
frame = build_binary_frame(
|
||||||
_start_freq, _end_freq, quantized,
|
_start_freq, _end_freq, quantized,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Drop frame if main loop cannot keep up.
|
||||||
|
with suppress(queue.Full):
|
||||||
_send_q.put_nowait(frame)
|
_send_q.put_nowait(frame)
|
||||||
except queue.Full:
|
|
||||||
# Drop frame if main loop can't keep up
|
monitor_cfg = _snapshot_monitor_config()
|
||||||
pass
|
if monitor_cfg:
|
||||||
|
center_mhz_cfg = float(monitor_cfg.get('center_mhz', _center_mhz))
|
||||||
|
monitor_mhz_cfg = float(monitor_cfg.get('monitor_freq_mhz', _center_mhz))
|
||||||
|
offset_hz = (monitor_mhz_cfg - center_mhz_cfg) * 1e6
|
||||||
|
if (
|
||||||
|
last_monitor_offset_hz is None
|
||||||
|
or abs(offset_hz - last_monitor_offset_hz) > 1.0
|
||||||
|
):
|
||||||
|
monitor_rotator_phase = 0.0
|
||||||
|
last_monitor_offset_hz = offset_hz
|
||||||
|
|
||||||
|
audio_chunk, monitor_rotator_phase = _demodulate_monitor_audio(
|
||||||
|
samples=samples,
|
||||||
|
sample_rate=_sample_rate,
|
||||||
|
center_mhz=center_mhz_cfg,
|
||||||
|
monitor_freq_mhz=monitor_mhz_cfg,
|
||||||
|
modulation=monitor_cfg.get('modulation', 'wfm'),
|
||||||
|
squelch=int(monitor_cfg.get('squelch', 0)),
|
||||||
|
rotator_phase=monitor_rotator_phase,
|
||||||
|
)
|
||||||
|
if audio_chunk:
|
||||||
|
_push_shared_audio_chunk(audio_chunk)
|
||||||
|
else:
|
||||||
|
monitor_rotator_phase = 0.0
|
||||||
|
last_monitor_offset_hz = None
|
||||||
|
|
||||||
# Pace to target FPS
|
# Pace to target FPS
|
||||||
elapsed = time.monotonic() - frame_start
|
elapsed = time.monotonic() - frame_start
|
||||||
@@ -333,13 +745,63 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
target=fft_reader,
|
target=fft_reader,
|
||||||
args=(
|
args=(
|
||||||
iq_process, send_queue, stop_event,
|
iq_process, send_queue, stop_event,
|
||||||
fft_size, avg_count, fps,
|
fft_size, avg_count, fps, sample_rate,
|
||||||
start_freq, end_freq,
|
start_freq, end_freq, center_freq_mhz,
|
||||||
|
db_min, db_max,
|
||||||
),
|
),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
reader_thread.start()
|
reader_thread.start()
|
||||||
|
|
||||||
|
elif cmd in ('tune', 'set_vfo'):
|
||||||
|
if not iq_process or claimed_device is None or iq_process.poll() is not None:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Waterfall capture is not running',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
vfo_freq_mhz = float(
|
||||||
|
data.get(
|
||||||
|
'vfo_freq_mhz',
|
||||||
|
data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
squelch = int(data.get('squelch', shared.get('monitor_squelch', 0)))
|
||||||
|
modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm')))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid tune request: {exc}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq):
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'retune_required',
|
||||||
|
'message': 'Frequency outside current capture span',
|
||||||
|
'capture_start_freq': capture_start_freq,
|
||||||
|
'capture_end_freq': capture_end_freq,
|
||||||
|
'vfo_freq_mhz': vfo_freq_mhz,
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
monitor_enabled = bool(shared.get('monitor_enabled'))
|
||||||
|
_set_shared_monitor(
|
||||||
|
enabled=monitor_enabled,
|
||||||
|
frequency_mhz=vfo_freq_mhz,
|
||||||
|
modulation=modulation,
|
||||||
|
squelch=squelch,
|
||||||
|
)
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'tuned',
|
||||||
|
'vfo_freq_mhz': vfo_freq_mhz,
|
||||||
|
'start_freq': capture_start_freq,
|
||||||
|
'end_freq': capture_end_freq,
|
||||||
|
'center_mhz': capture_center_mhz,
|
||||||
|
}))
|
||||||
|
|
||||||
elif cmd == 'stop':
|
elif cmd == 'stop':
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
if reader_thread and reader_thread.is_alive():
|
if reader_thread and reader_thread.is_alive():
|
||||||
@@ -350,15 +812,19 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
|
my_generation = None
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
ws.send(json.dumps({'status': 'stopped'}))
|
ws.send(json.dumps({'status': 'stopped'}))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(f"WebSocket waterfall closed: {e}")
|
logger.info(f"WebSocket waterfall closed: {e}")
|
||||||
finally:
|
finally:
|
||||||
# Cleanup
|
# Cleanup — use generation guard so a stale handler cannot
|
||||||
|
# clobber shared state owned by a newer WS connection.
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
if reader_thread and reader_thread.is_alive():
|
if reader_thread and reader_thread.is_alive():
|
||||||
reader_thread.join(timeout=2)
|
reader_thread.join(timeout=2)
|
||||||
@@ -366,21 +832,16 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
safe_terminate(iq_process)
|
safe_terminate(iq_process)
|
||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||||
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
# Complete WebSocket close handshake, then shut down the
|
# Complete WebSocket close handshake, then shut down the
|
||||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
# on top of the WebSocket stream (which browsers see as
|
# on top of the WebSocket stream (which browsers see as
|
||||||
# "Invalid frame header").
|
# "Invalid frame header").
|
||||||
try:
|
with suppress(Exception):
|
||||||
ws.close()
|
ws.close()
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.close()
|
ws.sock.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("WebSocket waterfall client disconnected")
|
logger.info("WebSocket waterfall client disconnected")
|
||||||
|
|||||||
+87
-36
@@ -12,12 +12,13 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream
|
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 (
|
from utils.weather_sat import (
|
||||||
get_weather_sat_decoder,
|
get_weather_sat_decoder,
|
||||||
is_weather_sat_available,
|
is_weather_sat_available,
|
||||||
CaptureProgress,
|
CaptureProgress,
|
||||||
WEATHER_SATELLITES,
|
WEATHER_SATELLITES,
|
||||||
|
DEFAULT_SAMPLE_RATE,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.weather_sat')
|
logger = get_logger('intercept.weather_sat')
|
||||||
@@ -40,6 +41,35 @@ def _progress_callback(progress: CaptureProgress) -> None:
|
|||||||
pass
|
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')
|
@weather_sat_bp.route('/status')
|
||||||
def get_status():
|
def get_status():
|
||||||
"""Get weather satellite decoder status.
|
"""Get weather satellite decoder status.
|
||||||
@@ -106,6 +136,13 @@ def start_capture():
|
|||||||
})
|
})
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
|
if sdr_type_str != 'rtlsdr':
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Validate satellite
|
# Validate satellite
|
||||||
satellite = data.get('satellite')
|
satellite = data.get('satellite')
|
||||||
@@ -128,18 +165,30 @@ def start_capture():
|
|||||||
|
|
||||||
bias_t = bool(data.get('bias_t', False))
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
|
||||||
# Claim SDR device
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
try:
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
import app as app_module
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
|
||||||
if error:
|
if rtl_tcp_host:
|
||||||
return jsonify({
|
try:
|
||||||
'status': 'error',
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
'error_type': 'DEVICE_BUSY',
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
'message': error,
|
except ValueError as e:
|
||||||
}), 409
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
except ImportError:
|
|
||||||
pass
|
# 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
|
# Clear queue
|
||||||
while not _weather_sat_queue.empty():
|
while not _weather_sat_queue.empty():
|
||||||
@@ -152,19 +201,19 @@ def start_capture():
|
|||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
|
|
||||||
def _release_device():
|
def _release_device():
|
||||||
try:
|
if not rtl_tcp_host:
|
||||||
import app as app_module
|
_release_weather_sat_device(device_index)
|
||||||
app_module.release_sdr_device(device_index)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
decoder.set_on_complete(_release_device)
|
decoder.set_on_complete(_release_device)
|
||||||
|
|
||||||
success = decoder.start(
|
success, error_msg = decoder.start(
|
||||||
satellite=satellite,
|
satellite=satellite,
|
||||||
device_index=device_index,
|
device_index=device_index,
|
||||||
gain=gain,
|
gain=gain,
|
||||||
|
sample_rate=DEFAULT_SAMPLE_RATE,
|
||||||
bias_t=bias_t,
|
bias_t=bias_t,
|
||||||
|
rtl_tcp_host=rtl_tcp_host,
|
||||||
|
rtl_tcp_port=rtl_tcp_port,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -181,7 +230,7 @@ def start_capture():
|
|||||||
_release_device()
|
_release_device()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start capture'
|
'message': error_msg or 'Failed to start capture'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -283,7 +332,7 @@ def test_decode():
|
|||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
decoder.set_on_complete(None)
|
decoder.set_on_complete(None)
|
||||||
|
|
||||||
success = decoder.start_from_file(
|
success, error_msg = decoder.start_from_file(
|
||||||
satellite=satellite,
|
satellite=satellite,
|
||||||
input_file=input_file,
|
input_file=input_file,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
@@ -302,7 +351,7 @@ def test_decode():
|
|||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start file decode'
|
'message': error_msg or 'Failed to start file decode'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -318,12 +367,7 @@ def stop_capture():
|
|||||||
|
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
# Release SDR device
|
_release_weather_sat_device(device_index)
|
||||||
try:
|
|
||||||
import app as app_module
|
|
||||||
app_module.release_sdr_device(device_index)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -566,14 +610,21 @@ def enable_schedule():
|
|||||||
scheduler = get_weather_sat_scheduler()
|
scheduler = get_weather_sat_scheduler()
|
||||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||||
|
|
||||||
result = scheduler.enable(
|
try:
|
||||||
lat=lat,
|
result = scheduler.enable(
|
||||||
lon=lon,
|
lat=lat,
|
||||||
min_elevation=min_elev,
|
lon=lon,
|
||||||
device=device,
|
min_elevation=min_elev,
|
||||||
gain=gain_val,
|
device=device,
|
||||||
bias_t=bool(data.get('bias_t', False)),
|
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})
|
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,
|
||||||
|
})
|
||||||
+22
-37
@@ -20,7 +20,7 @@ from utils.dependencies import check_tool, get_tool_path
|
|||||||
from utils.logging import wifi_logger as logger
|
from utils.logging import wifi_logger as logger
|
||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse, sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -1135,26 +1135,19 @@ def get_wifi_networks():
|
|||||||
@wifi_bp.route('/stream')
|
@wifi_bp.route('/stream')
|
||||||
def stream_wifi():
|
def stream_wifi():
|
||||||
"""SSE stream for WiFi events."""
|
"""SSE stream for WiFi events."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('wifi', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.wifi_queue.get(timeout=1)
|
source_queue=app_module.wifi_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='wifi',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('wifi', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
@@ -1557,23 +1550,15 @@ def v2_deauth_stream():
|
|||||||
- deauth_error: An error occurred
|
- deauth_error: An error occurred
|
||||||
- keepalive: Periodic keepalive
|
- keepalive: Periodic keepalive
|
||||||
"""
|
"""
|
||||||
def generate():
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
source_queue=app_module.deauth_detector_queue,
|
||||||
|
channel_key='wifi_deauth',
|
||||||
while True:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
try:
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
# Try to get from the dedicated deauth queue
|
),
|
||||||
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
mimetype='text/event-stream',
|
||||||
last_keepalive = time.time()
|
)
|
||||||
yield format_sse(msg)
|
|
||||||
except queue.Empty:
|
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -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;
|
--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 {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
@@ -393,7 +414,7 @@ body {
|
|||||||
.acars-sidebar .acars-btn {
|
.acars-sidebar .acars-btn {
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -554,7 +575,7 @@ body {
|
|||||||
.vdl2-sidebar .vdl2-btn {
|
.vdl2-sidebar .vdl2-btn {
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -872,6 +893,92 @@ body {
|
|||||||
display: block;
|
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 */
|
/* Right sidebar - Mobile first */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1139,7 +1246,7 @@ body {
|
|||||||
|
|
||||||
.control-group select {
|
.control-group select {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--bg-dark);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1240,7 +1347,7 @@ body {
|
|||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1258,7 +1365,7 @@ body {
|
|||||||
|
|
||||||
.start-btn.active {
|
.start-btn.active {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-btn.active:hover {
|
.start-btn.active:hover {
|
||||||
@@ -1390,7 +1497,7 @@ body {
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1411,7 +1518,7 @@ body {
|
|||||||
|
|
||||||
.airband-btn.active {
|
.airband-btn.active {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.airband-btn.active:hover {
|
.airband-btn.active:hover {
|
||||||
@@ -1805,7 +1912,7 @@ body {
|
|||||||
.strip-report-btn {
|
.strip-report-btn {
|
||||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -2117,7 +2224,7 @@ body {
|
|||||||
.strip-btn.primary {
|
.strip-btn.primary {
|
||||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.strip-btn.primary:hover {
|
.strip-btn.primary:hover {
|
||||||
|
|||||||
@@ -269,6 +269,21 @@ body {
|
|||||||
min-width: 160px;
|
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 {
|
.primary-btn {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -285,6 +300,31 @@ body {
|
|||||||
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
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 {
|
.status-pill {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -296,6 +336,16 @@ body {
|
|||||||
letter-spacing: 1px;
|
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 {
|
.content-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||||
@@ -364,6 +414,37 @@ body {
|
|||||||
background: rgba(74, 158, 255, 0.1);
|
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 {
|
.mono {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -614,6 +695,15 @@ body {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-actions input[type="date"],
|
||||||
|
.data-actions .primary-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,27 @@
|
|||||||
--radar-bg: #101823;
|
--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 {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
@@ -373,7 +394,7 @@ body {
|
|||||||
.strip-btn.primary {
|
.strip-btn.primary {
|
||||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid - Mobile first */
|
/* Main dashboard grid - Mobile first */
|
||||||
@@ -758,7 +779,7 @@ body {
|
|||||||
|
|
||||||
.control-group select {
|
.control-group select {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--bg-dark);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -791,7 +812,7 @@ body {
|
|||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -809,7 +830,7 @@ body {
|
|||||||
|
|
||||||
.start-btn.active {
|
.start-btn.active {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-btn.active:hover {
|
.start-btn.active:hover {
|
||||||
@@ -1261,7 +1282,7 @@ body {
|
|||||||
.dsc-distress-alert button {
|
.dsc-distress-alert button {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1292,7 +1313,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
|
|
||||||
.activity-timeline-btn.active {
|
.activity-timeline-btn.active {
|
||||||
background: var(--timeline-accent);
|
background: var(--timeline-accent);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--timeline-accent);
|
border-color: var(--timeline-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
.function-strip .strip-btn.primary {
|
.function-strip .strip-btn.primary {
|
||||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
.function-strip .strip-btn.stop {
|
.function-strip .strip-btn.stop {
|
||||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
border-color: rgba(0, 122, 255, 0.3);
|
border-color: rgba(0, 122, 255, 0.3);
|
||||||
}
|
}
|
||||||
.function-strip.bt-strip .strip-value {
|
.function-strip.bt-strip .strip-value {
|
||||||
color: #0a84ff;
|
color: var(--accent-blue, #0a84ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-strip.wifi-strip .strip-stat {
|
.function-strip.wifi-strip .strip-stat {
|
||||||
@@ -332,24 +332,24 @@
|
|||||||
border-color: rgba(255, 59, 48, 0.6);
|
border-color: rgba(255, 59, 48, 0.6);
|
||||||
}
|
}
|
||||||
.function-strip.tscm-strip .strip-value {
|
.function-strip.tscm-strip .strip-value {
|
||||||
color: #ef4444; /* Explicit red color */
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
.function-strip.tscm-strip .strip-label {
|
.function-strip.tscm-strip .strip-label {
|
||||||
color: #9ca3af; /* Explicit light gray */
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.function-strip.tscm-strip .strip-select {
|
.function-strip.tscm-strip .strip-select {
|
||||||
color: #e8eaed; /* Explicit white for selects */
|
color: var(--text-primary);
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
.function-strip.tscm-strip .strip-btn {
|
.function-strip.tscm-strip .strip-btn {
|
||||||
color: #e8eaed; /* Explicit white for buttons */
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.function-strip.tscm-strip .strip-tool {
|
.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-time,
|
||||||
.function-strip.tscm-strip .strip-status span {
|
.function-strip.tscm-strip .strip-status span {
|
||||||
color: #9ca3af; /* Explicit gray for status/time */
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-strip.rtlamr-strip .strip-stat {
|
.function-strip.rtlamr-strip .strip-stat {
|
||||||
@@ -361,7 +361,7 @@
|
|||||||
border-color: rgba(175, 82, 222, 0.3);
|
border-color: rgba(175, 82, 222, 0.3);
|
||||||
}
|
}
|
||||||
.function-strip.rtlamr-strip .strip-value {
|
.function-strip.rtlamr-strip .strip-value {
|
||||||
color: #af52de;
|
color: var(--accent-purple, #af52de);
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-strip.listening-strip .strip-stat {
|
.function-strip.listening-strip .strip-stat {
|
||||||
|
|||||||
@@ -13,13 +13,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.radar-device {
|
.radar-device {
|
||||||
transition: transform 0.2s ease;
|
|
||||||
transform-origin: center center;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-device:hover {
|
.radar-device:hover .radar-dot {
|
||||||
transform: scale(1.2);
|
filter: brightness(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invisible larger hit area to prevent hover flicker */
|
/* Invisible larger hit area to prevent hover flicker */
|
||||||
@@ -54,19 +52,19 @@
|
|||||||
|
|
||||||
.bt-radar-filter-btn:hover {
|
.bt-radar-filter-btn:hover {
|
||||||
background: var(--bg-hover, #333) !important;
|
background: var(--bg-hover, #333) !important;
|
||||||
color: #fff !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-radar-filter-btn.active {
|
.bt-radar-filter-btn.active {
|
||||||
background: #00d4ff !important;
|
background: var(--accent-cyan) !important;
|
||||||
color: #000 !important;
|
color: var(--text-inverse) !important;
|
||||||
border-color: #00d4ff !important;
|
border-color: var(--accent-cyan) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#btRadarPauseBtn.active {
|
#btRadarPauseBtn.active {
|
||||||
background: #f97316 !important;
|
background: var(--accent-orange) !important;
|
||||||
color: #000 !important;
|
color: var(--text-inverse) !important;
|
||||||
border-color: #f97316 !important;
|
border-color: var(--accent-orange) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -122,9 +120,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heatmap-btn.active {
|
.heatmap-btn.active {
|
||||||
background: #f97316;
|
background: var(--accent-orange);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: #f97316;
|
border-color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-heatmap-content {
|
.timeline-heatmap-content {
|
||||||
@@ -143,7 +141,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heatmap-error {
|
.heatmap-error {
|
||||||
color: #ef4444;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heatmap-grid {
|
.heatmap-grid {
|
||||||
|
|||||||
@@ -279,19 +279,19 @@
|
|||||||
|
|
||||||
.signal-proto-badge.aprs {
|
.signal-proto-badge.aprs {
|
||||||
background: rgba(6, 182, 212, 0.15);
|
background: rgba(6, 182, 212, 0.15);
|
||||||
color: #06b6d4;
|
color: var(--proto-aprs, #06b6d4);
|
||||||
border-color: rgba(6, 182, 212, 0.25);
|
border-color: rgba(6, 182, 212, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-proto-badge.ais {
|
.signal-proto-badge.ais {
|
||||||
background: rgba(139, 92, 246, 0.15);
|
background: rgba(139, 92, 246, 0.15);
|
||||||
color: #8b5cf6;
|
color: var(--proto-ais, #8b5cf6);
|
||||||
border-color: rgba(139, 92, 246, 0.25);
|
border-color: rgba(139, 92, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-proto-badge.acars {
|
.signal-proto-badge.acars {
|
||||||
background: rgba(236, 72, 153, 0.15);
|
background: rgba(236, 72, 153, 0.15);
|
||||||
color: #ec4899;
|
color: var(--proto-acars, #ec4899);
|
||||||
border-color: rgba(236, 72, 153, 0.25);
|
border-color: rgba(236, 72, 153, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,25 +976,25 @@
|
|||||||
/* Meter protocol badges */
|
/* Meter protocol badges */
|
||||||
.signal-proto-badge.meter {
|
.signal-proto-badge.meter {
|
||||||
background: rgba(234, 179, 8, 0.15);
|
background: rgba(234, 179, 8, 0.15);
|
||||||
color: #eab308;
|
color: var(--accent-yellow, #eab308);
|
||||||
border-color: rgba(234, 179, 8, 0.25);
|
border-color: rgba(234, 179, 8, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-proto-badge.meter.electric {
|
.signal-proto-badge.meter.electric {
|
||||||
background: rgba(234, 179, 8, 0.15);
|
background: rgba(234, 179, 8, 0.15);
|
||||||
color: #eab308;
|
color: var(--accent-yellow, #eab308);
|
||||||
border-color: rgba(234, 179, 8, 0.25);
|
border-color: rgba(234, 179, 8, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-proto-badge.meter.gas {
|
.signal-proto-badge.meter.gas {
|
||||||
background: rgba(249, 115, 22, 0.15);
|
background: rgba(249, 115, 22, 0.15);
|
||||||
color: #f97316;
|
color: var(--accent-orange, #f97316);
|
||||||
border-color: rgba(249, 115, 22, 0.25);
|
border-color: rgba(249, 115, 22, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-proto-badge.meter.water {
|
.signal-proto-badge.meter.water {
|
||||||
background: rgba(59, 130, 246, 0.15);
|
background: rgba(59, 130, 246, 0.15);
|
||||||
color: #3b82f6;
|
color: var(--signal-new, #3b82f6);
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,12 +1060,12 @@
|
|||||||
|
|
||||||
.meter-delta.positive {
|
.meter-delta.positive {
|
||||||
background: rgba(34, 197, 94, 0.15);
|
background: rgba(34, 197, 94, 0.15);
|
||||||
color: #22c55e;
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meter-delta.negative {
|
.meter-delta.negative {
|
||||||
background: rgba(239, 68, 68, 0.15);
|
background: rgba(239, 68, 68, 0.15);
|
||||||
color: #ef4444;
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sparkline container */
|
/* Sparkline container */
|
||||||
@@ -1431,7 +1431,7 @@
|
|||||||
|
|
||||||
.signal-station-clickable:hover {
|
.signal-station-clickable:hover {
|
||||||
background: var(--accent-purple);
|
background: var(--accent-purple);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
|
box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
|
||||||
}
|
}
|
||||||
@@ -1587,14 +1587,14 @@
|
|||||||
background: var(--accent-purple, #8a2be2);
|
background: var(--accent-purple, #8a2be2);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.station-raw-copy-btn:hover {
|
.station-raw-copy-btn:hover {
|
||||||
background: var(--accent-cyan, #00d4ff);
|
background: var(--accent-cyan, #00d4ff);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -1794,14 +1794,14 @@
|
|||||||
background: var(--accent-purple, #8a2be2);
|
background: var(--accent-purple, #8a2be2);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-copy-btn:hover {
|
.signal-details-copy-btn:hover {
|
||||||
background: var(--accent-cyan, #00d4ff);
|
background: var(--accent-cyan, #00d4ff);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Signal Details Content Sections */
|
/* Signal Details Content Sections */
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
|
|
||||||
.signal-timeline-btn.active {
|
.signal-timeline-btn.active {
|
||||||
background: var(--accent-cyan, #4a9eff);
|
background: var(--accent-cyan, #4a9eff);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-cyan, #4a9eff);
|
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 {
|
.update-toast-btn-primary {
|
||||||
background: var(--accent-green, #22c55e);
|
background: var(--accent-green, #22c55e);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-toast-btn-primary:hover {
|
.update-toast-btn-primary:hover {
|
||||||
@@ -561,7 +561,7 @@
|
|||||||
|
|
||||||
.update-modal-btn-primary {
|
.update-modal-btn-primary {
|
||||||
background: var(--accent-green, #22c55e);
|
background: var(--accent-green, #22c55e);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-modal-btn-primary:hover:not(:disabled) {
|
.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);
|
color: var(--text-primary);
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
background-image:
|
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),
|
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(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, 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;
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-red);
|
border-color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-green);
|
border-color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,11 +123,12 @@
|
|||||||
CARDS / PANELS
|
CARDS / PANELS
|
||||||
============================================ */
|
============================================ */
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
background: var(--surface-panel-gradient);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -135,8 +136,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,11 +161,12 @@
|
|||||||
|
|
||||||
/* Panel variant (used in dashboards) */
|
/* Panel variant (used in dashboards) */
|
||||||
.panel {
|
.panel {
|
||||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
background: var(--surface-panel-gradient);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (clip-path: polygon(0 0)) {
|
@supports (clip-path: polygon(0 0)) {
|
||||||
@@ -190,8 +192,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -722,7 +724,20 @@
|
|||||||
|
|
||||||
.card:hover,
|
.card:hover,
|
||||||
.panel:hover {
|
.panel:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-glow);
|
||||||
|
box-shadow: var(--shadow-md), var(--shadow-glow), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .card,
|
||||||
|
[data-theme="light"] .panel {
|
||||||
|
border-color: rgba(31, 95, 168, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .card-header,
|
||||||
|
[data-theme="light"] .panel-header {
|
||||||
|
border-bottom-color: rgba(31, 95, 168, 0.2);
|
||||||
|
background: linear-gradient(180deg, rgba(243, 247, 252, 0.96) 0%, rgba(233, 239, 247, 0.95) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats strip value highlight on hover */
|
/* Stats strip value highlight on hover */
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
--bg-card: #121a25;
|
--bg-card: #121a25;
|
||||||
--bg-elevated: #1b2734;
|
--bg-elevated: #1b2734;
|
||||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
--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 */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
@@ -56,6 +61,18 @@
|
|||||||
--status-offline: #6f7f94;
|
--status-offline: #6f7f94;
|
||||||
--status-info: #4aa3ff;
|
--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 */
|
/* Subtle grid/pattern */
|
||||||
--grid-line: rgba(74, 163, 255, 0.1);
|
--grid-line: rgba(74, 163, 255, 0.1);
|
||||||
--grid-dot: rgba(255, 255, 255, 0.03);
|
--grid-dot: rgba(255, 255, 255, 0.03);
|
||||||
@@ -158,6 +175,11 @@
|
|||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-elevated: #f1f4f9;
|
--bg-elevated: #f1f4f9;
|
||||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
--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 */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
@@ -174,6 +196,27 @@
|
|||||||
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||||
--accent-amber: #b5863a;
|
--accent-amber: #b5863a;
|
||||||
--accent-amber-dim: rgba(181, 134, 58, 0.12);
|
--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-primary: #122034;
|
||||||
--text-secondary: #3a4a5f;
|
--text-secondary: #3a4a5f;
|
||||||
|
|||||||
@@ -382,6 +382,74 @@
|
|||||||
transform: rotate(90deg);
|
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 */
|
/* Effects/animations toggle icon states */
|
||||||
.nav-tool-btn .icon-effects-off {
|
.nav-tool-btn .icon-effects-off {
|
||||||
display: none;
|
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); }
|
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); }
|
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;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-select {
|
.aprs-strip .strip-select {
|
||||||
background: rgba(0,0,0,0.3);
|
background: var(--bg-dark);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
.aprs-strip .strip-btn.primary {
|
.aprs-strip .strip-btn.primary {
|
||||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
.aprs-strip .strip-btn.stop {
|
.aprs-strip .strip-btn.stop {
|
||||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
|
|||||||
@@ -170,6 +170,23 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.btl-hud-audio-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -249,24 +266,115 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-container {
|
.btl-map-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
|
position: relative;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#btLocateMap {
|
#btLocateMap {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
inset: 0;
|
||||||
background: #1a1a2e;
|
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 {
|
.btl-rssi-chart-container {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
@@ -426,5 +534,95 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-5
@@ -140,14 +140,114 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gps-skyview-canvas-wrap {
|
.gps-skyview-canvas-wrap {
|
||||||
display: flex;
|
position: relative;
|
||||||
justify-content: center;
|
display: block;
|
||||||
align-items: center;
|
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 {
|
#gpsSkyCanvas {
|
||||||
max-width: 100%;
|
position: absolute;
|
||||||
height: auto;
|
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 */
|
/* Position info panel */
|
||||||
|
|||||||
@@ -303,7 +303,7 @@
|
|||||||
|
|
||||||
.mesh-strip-btn.disconnect {
|
.mesh-strip-btn.disconnect {
|
||||||
background: var(--accent-red, #ff3366);
|
background: var(--accent-red, #ff3366);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-btn.disconnect:hover {
|
.mesh-strip-btn.disconnect:hover {
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
0 2px 8px rgba(0, 0, 0, 0.6),
|
0 2px 8px rgba(0, 0, 0, 0.6),
|
||||||
0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
|
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 */
|
inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -1088,7 +1088,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1231,7 +1231,7 @@
|
|||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1413,7 +1413,7 @@
|
|||||||
.mesh-position-btn:hover,
|
.mesh-position-btn:hover,
|
||||||
.mesh-telemetry-btn:hover {
|
.mesh-telemetry-btn:hover {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1435,7 +1435,7 @@
|
|||||||
|
|
||||||
.mesh-qr-btn:hover {
|
.mesh-qr-btn:hover {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-cyan);
|
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 {
|
.spy-freq-clickable:hover {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
|
|
||||||
.sstv-general-strip-btn.stop {
|
.sstv-general-strip-btn.stop {
|
||||||
background: var(--accent-red, #ff3366);
|
background: var(--accent-red, #ff3366);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-general-strip-btn.stop:hover {
|
.sstv-general-strip-btn.stop:hover {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
|
|
||||||
.sstv-strip-btn.stop {
|
.sstv-strip-btn.stop {
|
||||||
background: var(--accent-red, #ff3366);
|
background: var(--accent-red, #ff3366);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-btn.stop:hover {
|
.sstv-strip-btn.stop:hover {
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
.sstv-strip-btn.gps:hover {
|
.sstv-strip-btn.gps:hover {
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-green);
|
border-color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
|
|
||||||
.sstv-strip-btn.update-tle:hover {
|
.sstv-strip-btn.update-tle:hover {
|
||||||
background: var(--accent-orange);
|
background: var(--accent-orange);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-orange);
|
border-color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-38
@@ -25,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-device-dot.connected {
|
.subghz-device-dot.connected {
|
||||||
background: #00ff88;
|
background: var(--neon-green);
|
||||||
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
|
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-device-dot.unknown {
|
.subghz-device-dot.unknown {
|
||||||
background: #ffaa00;
|
background: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-device-label {
|
.subghz-device-label {
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
.subghz-tool-badge.available {
|
.subghz-tool-badge.available {
|
||||||
border-color: rgba(0, 255, 136, 0.3);
|
border-color: rgba(0, 255, 136, 0.3);
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
background: rgba(0, 255, 136, 0.05);
|
background: rgba(0, 255, 136, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
.subghz-preset-btn:hover {
|
.subghz-preset-btn:hover {
|
||||||
background: var(--accent-cyan, #00d4ff);
|
background: var(--accent-cyan, #00d4ff);
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-cyan, #00d4ff);
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-status-dot.rx {
|
.subghz-status-dot.rx {
|
||||||
background: #00ff88;
|
background: var(--neon-green);
|
||||||
animation: subghz-pulse 1.5s ease-in-out infinite;
|
animation: subghz-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-status-dot.sweep {
|
.subghz-status-dot.sweep {
|
||||||
background: #ffaa00;
|
background: var(--neon-orange);
|
||||||
animation: subghz-pulse 1s ease-in-out infinite;
|
animation: subghz-pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
.subghz-btn.start {
|
.subghz-btn.start {
|
||||||
background: var(--accent-green, #22c55e);
|
background: var(--accent-green, #22c55e);
|
||||||
border-color: var(--accent-green, #22c55e);
|
border-color: var(--accent-green, #22c55e);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
.subghz-btn.stop {
|
.subghz-btn.stop {
|
||||||
background: var(--accent-red, #ff4444);
|
background: var(--accent-red, #ff4444);
|
||||||
border-color: var(--accent-red, #ff4444);
|
border-color: var(--accent-red, #ff4444);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(255, 170, 0, 0.55);
|
border: 1px solid rgba(255, 170, 0, 0.55);
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
@@ -423,14 +423,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-capture-burst-flag {
|
.subghz-capture-burst-flag {
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-capture-burst-count {
|
.subghz-capture-burst-count {
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(255, 170, 0, 0.55);
|
border: 1px solid rgba(255, 170, 0, 0.55);
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
background: rgba(255, 170, 0, 0.12);
|
background: rgba(255, 170, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +462,7 @@
|
|||||||
|
|
||||||
.subghz-capture-tag.hint {
|
.subghz-capture-tag.hint {
|
||||||
border-color: rgba(0, 255, 136, 0.5);
|
border-color: rgba(0, 255, 136, 0.5);
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
background: rgba(0, 255, 136, 0.12);
|
background: rgba(0, 255, 136, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,7 +892,7 @@
|
|||||||
|
|
||||||
.subghz-tx-confirm-btn {
|
.subghz-tx-confirm-btn {
|
||||||
background: var(--accent-red, #ff4444);
|
background: var(--accent-red, #ff4444);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-red, #ff4444) !important;
|
border-color: var(--accent-red, #ff4444) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,7 +956,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-sweep-tooltip .tip-power {
|
.subghz-sweep-tooltip .tip-power {
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right-click context menu */
|
/* Right-click context menu */
|
||||||
@@ -1038,8 +1038,8 @@
|
|||||||
|
|
||||||
.subghz-action-btn.tune:hover {
|
.subghz-action-btn.tune:hover {
|
||||||
background: rgba(0, 255, 136, 0.12);
|
background: rgba(0, 255, 136, 0.12);
|
||||||
border-color: #00ff88;
|
border-color: var(--neon-green);
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-action-btn.decode:hover {
|
.subghz-action-btn.decode:hover {
|
||||||
@@ -1050,8 +1050,8 @@
|
|||||||
|
|
||||||
.subghz-action-btn.capture:hover {
|
.subghz-action-btn.capture:hover {
|
||||||
background: rgba(255, 170, 0, 0.12);
|
background: rgba(255, 170, 0, 0.12);
|
||||||
border-color: #ffaa00;
|
border-color: var(--neon-orange);
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Peak list in sidebar */
|
/* Peak list in sidebar */
|
||||||
@@ -1088,7 +1088,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-peak-item:hover {
|
.subghz-peak-item:hover {
|
||||||
border-color: #ffaa00;
|
border-color: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-peak-item .peak-freq {
|
.subghz-peak-item .peak-freq {
|
||||||
@@ -1096,7 +1096,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-peak-item .peak-power {
|
.subghz-peak-item .peak-power {
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Stats Strip ===== */
|
/* ===== Stats Strip ===== */
|
||||||
@@ -1147,10 +1147,10 @@
|
|||||||
animation: subghz-pulse 1.5s ease-in-out infinite;
|
animation: subghz-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-strip-dot.rx { background: #00ff88; }
|
.subghz-strip-dot.rx { background: var(--neon-green); }
|
||||||
.subghz-strip-dot.decode { background: #00d4ff; }
|
.subghz-strip-dot.decode { background: #00d4ff; }
|
||||||
.subghz-strip-dot.tx { background: #ff4444; }
|
.subghz-strip-dot.tx { background: #ff4444; }
|
||||||
.subghz-strip-dot.sweep { background: #ffaa00; }
|
.subghz-strip-dot.sweep { background: var(--neon-orange); }
|
||||||
|
|
||||||
.subghz-strip-status-text {
|
.subghz-strip-status-text {
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
@@ -1170,8 +1170,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-strip-value.accent-cyan { color: var(--accent-cyan, #00d4ff); }
|
.subghz-strip-value.accent-cyan { color: var(--accent-cyan, #00d4ff); }
|
||||||
.subghz-strip-value.accent-green { color: #00ff88; }
|
.subghz-strip-value.accent-green { color: var(--neon-green); }
|
||||||
.subghz-strip-value.accent-orange { color: #ffaa00; }
|
.subghz-strip-value.accent-orange { color: var(--neon-orange); }
|
||||||
|
|
||||||
.subghz-strip-label {
|
.subghz-strip-label {
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
@@ -1225,7 +1225,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-strip-device-dot.connected {
|
.subghz-strip-device-dot.connected {
|
||||||
background: #00ff88;
|
background: var(--neon-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-strip-device-dot.disconnected {
|
.subghz-strip-device-dot.disconnected {
|
||||||
@@ -1233,7 +1233,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-strip-device-dot.unknown {
|
.subghz-strip-device-dot.unknown {
|
||||||
background: #ffaa00;
|
background: var(--neon-orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Signal Console ===== */
|
/* ===== Signal Console ===== */
|
||||||
@@ -1279,7 +1279,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-phase-step.completed {
|
.subghz-phase-step.completed {
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-phase-step.error {
|
.subghz-phase-step.error {
|
||||||
@@ -1317,12 +1317,12 @@
|
|||||||
|
|
||||||
.subghz-burst-indicator.active {
|
.subghz-burst-indicator.active {
|
||||||
border-color: rgba(255, 170, 0, 0.6);
|
border-color: rgba(255, 170, 0, 0.6);
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
background: rgba(255, 170, 0, 0.12);
|
background: rgba(255, 170, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-burst-indicator.active .subghz-burst-dot {
|
.subghz-burst-indicator.active .subghz-burst-dot {
|
||||||
background: #ffaa00;
|
background: var(--neon-orange);
|
||||||
box-shadow: 0 0 10px rgba(255, 170, 0, 0.7);
|
box-shadow: 0 0 10px rgba(255, 170, 0, 0.7);
|
||||||
animation: subghz-pulse 0.45s ease-in-out infinite;
|
animation: subghz-pulse 0.45s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -1383,8 +1383,8 @@
|
|||||||
|
|
||||||
.subghz-log-msg { color: var(--text-secondary, #999); }
|
.subghz-log-msg { color: var(--text-secondary, #999); }
|
||||||
.subghz-log-msg.info { color: var(--accent-cyan, #00d4ff); }
|
.subghz-log-msg.info { color: var(--accent-cyan, #00d4ff); }
|
||||||
.subghz-log-msg.success { color: #00ff88; }
|
.subghz-log-msg.success { color: var(--neon-green); }
|
||||||
.subghz-log-msg.warn { color: #ffaa00; }
|
.subghz-log-msg.warn { color: var(--neon-orange); }
|
||||||
.subghz-log-msg.error { color: var(--accent-red, #ff4444); }
|
.subghz-log-msg.error { color: var(--accent-red, #ff4444); }
|
||||||
|
|
||||||
/* ===== Action Hub ===== */
|
/* ===== Action Hub ===== */
|
||||||
@@ -1450,12 +1450,12 @@
|
|||||||
.subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, #00d4ff); }
|
.subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, #00d4ff); }
|
||||||
|
|
||||||
.subghz-hub-card--green { border-color: rgba(0, 255, 136, 0.2); }
|
.subghz-hub-card--green { border-color: rgba(0, 255, 136, 0.2); }
|
||||||
.subghz-hub-card--green:hover { border-color: #00ff88; background: rgba(0, 255, 136, 0.05); }
|
.subghz-hub-card--green:hover { border-color: var(--neon-green); background: rgba(0, 255, 136, 0.05); }
|
||||||
.subghz-hub-card--green .subghz-hub-icon { color: #00ff88; }
|
.subghz-hub-card--green .subghz-hub-icon { color: var(--neon-green); }
|
||||||
|
|
||||||
.subghz-hub-card--orange { border-color: rgba(255, 170, 0, 0.2); }
|
.subghz-hub-card--orange { border-color: rgba(255, 170, 0, 0.2); }
|
||||||
.subghz-hub-card--orange:hover { border-color: #ffaa00; background: rgba(255, 170, 0, 0.05); }
|
.subghz-hub-card--orange:hover { border-color: var(--neon-orange); background: rgba(255, 170, 0, 0.05); }
|
||||||
.subghz-hub-card--orange .subghz-hub-icon { color: #ffaa00; }
|
.subghz-hub-card--orange .subghz-hub-icon { color: var(--neon-orange); }
|
||||||
|
|
||||||
.subghz-hub-card--red { border-color: rgba(255, 68, 68, 0.25); }
|
.subghz-hub-card--red { border-color: rgba(255, 68, 68, 0.25); }
|
||||||
.subghz-hub-card--red:hover { border-color: #ff4444; background: rgba(255, 68, 68, 0.08); }
|
.subghz-hub-card--red:hover { border-color: #ff4444; background: rgba(255, 68, 68, 0.08); }
|
||||||
@@ -1707,7 +1707,7 @@
|
|||||||
|
|
||||||
.subghz-rx-hint-confidence {
|
.subghz-rx-hint-confidence {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #00ff88;
|
color: var(--neon-green);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.35);
|
border: 1px solid rgba(0, 255, 136, 0.35);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
@@ -1729,7 +1729,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-burst-pill.active {
|
.subghz-rx-burst-pill.active {
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
border-color: rgba(255, 170, 0, 0.7);
|
border-color: rgba(255, 170, 0, 0.7);
|
||||||
background: rgba(255, 170, 0, 0.15);
|
background: rgba(255, 170, 0, 0.15);
|
||||||
}
|
}
|
||||||
@@ -1866,7 +1866,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-wf-pause-btn.paused {
|
.subghz-wf-pause-btn.paused {
|
||||||
color: #ffaa00;
|
color: var(--neon-orange);
|
||||||
border-color: rgba(255, 170, 0, 0.6);
|
border-color: rgba(255, 170, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,579 @@
|
|||||||
|
/* System Health Mode Styles — Enhanced Dashboard */
|
||||||
|
|
||||||
|
.sys-dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group headers span full width */
|
||||||
|
.sys-group-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-group-header:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.sys-card {
|
||||||
|
background: var(--bg-card, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a4a);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-header {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metric Bars */
|
||||||
|
.sys-metric-bar-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-bar-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
min-width: 40px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-primary, #0d0d1a);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-bar-fill.ok {
|
||||||
|
background: var(--accent-green, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-bar-fill.warn {
|
||||||
|
background: var(--accent-yellow, #ffcc00);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-bar-fill.crit {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-bar-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-metric-na {
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG Arc Gauge */
|
||||||
|
.sys-gauge-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gauge-arc {
|
||||||
|
position: relative;
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gauge-arc svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gauge-arc .arc-bg {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--bg-primary, #0d0d1a);
|
||||||
|
stroke-width: 8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gauge-arc .arc-fill {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
|
||||||
|
filter: drop-shadow(0 0 4px currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gauge-arc .arc-fill.ok { stroke: var(--accent-green, #00ff88); }
|
||||||
|
.sys-gauge-arc .arc-fill.warn { stroke: var(--accent-yellow, #ffcc00); }
|
||||||
|
.sys-gauge-arc .arc-fill.crit { stroke: var(--accent-red, #ff3366); }
|
||||||
|
|
||||||
|
.sys-gauge-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gauge-details {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-core bars */
|
||||||
|
.sys-core-bars {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-core-bar {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-primary, #0d0d1a);
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
min-width: 6px;
|
||||||
|
max-width: 32px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-core-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: height 0.4s ease, background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Temperature sparkline */
|
||||||
|
.sys-sparkline-wrap {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-sparkline-wrap svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-sparkline-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-cyan, #00d4ff);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-sparkline-area {
|
||||||
|
fill: url(#sparkGradient);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-temp-big {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Network interface rows */
|
||||||
|
.sys-net-iface {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a2a4a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-net-iface:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-net-iface-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-net-iface-ip {
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-net-iface-detail {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bandwidth arrows */
|
||||||
|
.sys-bandwidth {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-bw-up {
|
||||||
|
color: var(--accent-green, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-bw-down {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Globe container — compact vertical layout */
|
||||||
|
.sys-location-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-globe-wrap {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-location-details {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS status indicator */
|
||||||
|
.sys-gps-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gps-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gps-dot.fix-3d {
|
||||||
|
background: var(--accent-green, #00ff88);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gps-dot.fix-2d {
|
||||||
|
background: var(--accent-yellow, #ffcc00);
|
||||||
|
box-shadow: 0 0 4px rgba(255, 204, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-gps-dot.no-fix {
|
||||||
|
background: var(--text-dim, #555);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-location-coords {
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-location-source {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weather overlay */
|
||||||
|
.sys-weather {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color, #2a2a4a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-weather-temp {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-weather-condition {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-weather-detail {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disk I/O indicators */
|
||||||
|
.sys-disk-io {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-disk-io-read {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-disk-io-write {
|
||||||
|
color: var(--accent-green, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Process grid — dot-matrix style */
|
||||||
|
.sys-process-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-process-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-process-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-process-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-process-dot.running {
|
||||||
|
background: var(--accent-green, #00ff88);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-process-dot.stopped {
|
||||||
|
background: var(--text-dim, #555);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-process-summary {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SDR Devices */
|
||||||
|
.sys-sdr-device {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a2a4a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-sdr-device:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-rescan-btn {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color, #2a2a4a);
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-rescan-btn:hover {
|
||||||
|
background: var(--bg-primary, #0d0d1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System info — vertical layout to fill card */
|
||||||
|
.sys-info-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-info-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-info-item strong {
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Battery indicator */
|
||||||
|
.sys-battery-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Quick Grid */
|
||||||
|
.sys-quick-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-quick-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-primary, #0d0d1a);
|
||||||
|
border: 1px solid var(--border-color, #2a2a4a);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-quick-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-quick-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
color: var(--text-primary, #e0e0ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color-coded quick values */
|
||||||
|
.sys-val-ok {
|
||||||
|
color: var(--accent-green, #00ff88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-val-warn {
|
||||||
|
color: var(--accent-yellow, #ffcc00) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-val-crit {
|
||||||
|
color: var(--accent-red, #ff3366) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sys-dashboard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-wide,
|
||||||
|
.sys-card-full {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-globe-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-process-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) and (min-width: 769px) {
|
||||||
|
.sys-dashboard {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
}
|
}
|
||||||
.tscm-panel-header .badge {
|
.tscm-panel-header .badge {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #000;
|
color: var(--text-inverse);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
.tscm-modal-close:hover {
|
.tscm-modal-close:hover {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
border-color: var(--accent-red);
|
border-color: var(--accent-red);
|
||||||
color: #fff;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
.device-detail-header {
|
.device-detail-header {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -654,10 +654,10 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.tscm-threat-item.critical .tscm-threat-severity { background: #ff3366; color: #fff; }
|
.tscm-threat-item.critical .tscm-threat-severity { background: var(--severity-critical); color: var(--text-inverse); }
|
||||||
.tscm-threat-item.high .tscm-threat-severity { background: #ff9933; color: #000; }
|
.tscm-threat-item.high .tscm-threat-severity { background: var(--severity-high); color: var(--text-inverse); }
|
||||||
.tscm-threat-item.medium .tscm-threat-severity { background: #ffcc00; color: #000; }
|
.tscm-threat-item.medium .tscm-threat-severity { background: var(--severity-medium); color: var(--text-inverse); }
|
||||||
.tscm-threat-item.low .tscm-threat-severity { background: #00ff88; color: #000; }
|
.tscm-threat-item.low .tscm-threat-severity { background: var(--severity-low); color: var(--text-inverse); }
|
||||||
.tscm-threat-details {
|
.tscm-threat-details {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1691,3 +1691,45 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light theme overrides */
|
||||||
|
[data-theme="light"] .threat-card.critical { border-color: var(--severity-critical); color: var(--severity-critical); }
|
||||||
|
[data-theme="light"] .threat-card.high { border-color: var(--severity-high); color: var(--severity-high); }
|
||||||
|
[data-theme="light"] .threat-card.medium { border-color: var(--severity-medium); color: var(--severity-medium); }
|
||||||
|
[data-theme="light"] .threat-card.low { border-color: var(--severity-low); color: var(--severity-low); }
|
||||||
|
[data-theme="light"] .tscm-threat-item.critical { border-color: var(--severity-critical); }
|
||||||
|
[data-theme="light"] .tscm-threat-item.high { border-color: var(--severity-high); }
|
||||||
|
[data-theme="light"] .tscm-threat-item.medium { border-color: var(--severity-medium); }
|
||||||
|
[data-theme="light"] .tscm-threat-item.low { border-color: var(--severity-low); }
|
||||||
|
[data-theme="light"] .score-badge.score-high { color: #cc2222; }
|
||||||
|
[data-theme="light"] .score-badge.score-medium { color: #9a8420; }
|
||||||
|
[data-theme="light"] .score-badge.score-low { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .score-circle.high { border-color: #cc2222; }
|
||||||
|
[data-theme="light"] .score-circle.medium { border-color: #9a8420; }
|
||||||
|
[data-theme="light"] .score-circle.low { border-color: #1a8a50; }
|
||||||
|
[data-theme="light"] .score-circle.high .score-value { color: #cc2222; }
|
||||||
|
[data-theme="light"] .score-circle.medium .score-value { color: #9a8420; }
|
||||||
|
[data-theme="light"] .score-circle.low .score-value { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .cap-status.available { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .cap-status.limited { color: #9a8420; }
|
||||||
|
[data-theme="light"] .cap-status.unavailable { color: #cc2222; }
|
||||||
|
[data-theme="light"] .cap-detail-status.available { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .cap-detail-status.limited { color: #9a8420; }
|
||||||
|
[data-theme="light"] .cap-detail-status.unavailable { color: #cc2222; }
|
||||||
|
[data-theme="light"] .health-badge.healthy { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .health-badge.noisy { color: #9a8420; }
|
||||||
|
[data-theme="light"] .health-badge.stale { color: #cc2222; }
|
||||||
|
[data-theme="light"] .tscm-assessment.high-interest { color: #cc2222; border-color: #cc2222; }
|
||||||
|
[data-theme="light"] .tscm-assessment.needs-review { color: #9a8420; border-color: #9a8420; }
|
||||||
|
[data-theme="light"] .tscm-assessment.informational { color: #1a8a50; border-color: #1a8a50; }
|
||||||
|
[data-theme="light"] .summary-stat.high-interest .count { color: #cc2222; }
|
||||||
|
[data-theme="light"] .summary-stat.needs-review .count { color: #9a8420; }
|
||||||
|
[data-theme="light"] .summary-stat.informational .count { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .known-badge { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .tracker-badge { color: #cc2244; }
|
||||||
|
[data-theme="light"] .tscm-correlations { border-color: var(--accent-orange); }
|
||||||
|
[data-theme="light"] .tscm-correlations h4 { color: var(--accent-orange); }
|
||||||
|
[data-theme="light"] .case-status.open { color: #1a8a50; }
|
||||||
|
[data-theme="light"] .playbook-risk.high_interest { color: #cc2222; }
|
||||||
|
[data-theme="light"] .playbook-risk.needs_review { color: #9a8420; }
|
||||||
|
[data-theme="light"] .playbook-risk.informational { color: #1a8a50; }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user