Compare commits
368 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cd64ce3ca | |||
| 9705e58691 | |||
| 3acdab816a | |||
| c31ed14041 | |||
| 7241dbed35 | |||
| 94b358f686 | |||
| 8e19f7e688 | |||
| 7ea06caaa2 | |||
| 5f480caa3f | |||
| 5d4b61b4c3 | |||
| a8e2b9d98d | |||
| 4b225db9da | |||
| aba4ccd040 | |||
| f8a6d0ae70 | |||
| 00681840c8 | |||
| 00be3e940a | |||
| fb2a12773a | |||
| 167f10c7f7 | |||
| e386016349 | |||
| aec925753e | |||
| c3bf30b49c | |||
| c0221ba53d | |||
| af5b17e841 | |||
| b628a5f751 | |||
| 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 | |||
| d427f69dcd | |||
| cab04e6e2c | |||
| 8969fefe2e | |||
| 5e9fcc5c49 | |||
| 53b23fc2f7 | |||
| eeb3a29ecf | |||
| 4cdfa98a4e | |||
| 9fcec6cbb8 | |||
| a527ac191a | |||
| 8cd3aafd10 | |||
| 5c76a423af | |||
| c80bf99b91 | |||
| 6e5cb0a23e | |||
| ffb98425f1 | |||
| 533e92c711 | |||
| 9f32b05719 | |||
| 2a05aaa4d8 | |||
| 6529febcfa | |||
| bd87d4b4c6 | |||
| 5a0589dd69 | |||
| 5605ae0359 | |||
| 2b3f351ff0 | |||
| 126b9ba2ee | |||
| c0498ebe68 | |||
| 99d52eafe7 | |||
| 2a73318457 | |||
| d8d08a8b1e | |||
| c60769f795 | |||
| 01f8324292 | |||
| c66988cc1c | |||
| fac3d4359b | |||
| d6f10d29ca | |||
| 332735cecf | |||
| b04e335f49 | |||
| 75e50a1cd4 | |||
| 243a0f0e7f | |||
| 7c3ec9e920 | |||
| 4639146f05 | |||
| a354fee792 | |||
| a1cb6b2692 | |||
| 8376415074 | |||
| b25615317b | |||
| 311d268b10 | |||
| 6581620cb0 | |||
| aa963519e9 | |||
| 4a6dddbb48 | |||
| f217230ef4 | |||
| e27b4d78cb | |||
| d41ba61aee | |||
| 35cf01c11e | |||
| 00c9a6fdd9 | |||
| fce66a6a60 | |||
| b023e4cdc7 | |||
| a8f2912b90 | |||
| a2a7ac8fec | |||
| 4e168ff502 | |||
| 51aba87852 | |||
| 4c13e98091 | |||
| 54c849ab60 | |||
| 94ee22fdd4 | |||
| b96eb8ccba | |||
| b8a80460bf | |||
| 7130c2d4c4 | |||
| 62c34c1e95 | |||
| e413f54651 | |||
| 1a4af214bf | |||
| c2891938ab | |||
| 2bed35dd64 | |||
| 0c656cff2b | |||
| e03ba3f5ed | |||
| c6ff8abf11 | |||
| eff6ca3e87 | |||
| 1a5b076a8d | |||
| 90e88fc469 | |||
| 98f6d18bea | |||
| 7d69cac7e7 | |||
| c6a8a4a492 | |||
| ca15e227cd | |||
| 391aff52ce | |||
| 3dc16b392b | |||
| 4d7be047da | |||
| 182e1f3239 | |||
| 87782319f2 | |||
| 6b7f817aa6 | |||
| 82f442ffb8 | |||
| 1924203c19 | |||
| f18ed26005 | |||
| 897cea5b54 | |||
| cd2d51ee40 | |||
| 39ed4bffba | |||
| 6010c7d589 | |||
| 01978730ba | |||
| 451eff83a8 | |||
| 7cb2efca30 | |||
| 33953fcf2b | |||
| 1eec4a2342 | |||
| 2dc4940ca2 | |||
| cd5f1464b6 | |||
| 4aeb51a973 | |||
| 15efe56762 | |||
| 995bc17418 | |||
| c3dcf1401a | |||
| 6f9873d47f | |||
| 28185727e3 | |||
| 48795f6ec3 | |||
| f5021a0fdf | |||
| 7312f330ed | |||
| 2115bc551d | |||
| f6c19af33a | |||
| ebd9eb81f2 | |||
| c87c01cdfe | |||
| 19a94d4a84 | |||
| cca04918a9 | |||
| 777b83f6e0 | |||
| 455bc05c69 | |||
| 37842dc1ef | |||
| 01f3cc845b | |||
| bdba56bef1 | |||
| a5ea632cc2 | |||
| a3b81bead8 | |||
| 026337a350 | |||
| 44b1a74838 | |||
| 7aae2944d4 | |||
| 766a51753d | |||
| 92e5e7c6da | |||
| 154dc898ff | |||
| beb38b6b98 | |||
| f04ba7f143 | |||
| fd0953bfb5 | |||
| b312eb20aa | |||
| 8eb8a2fe97 | |||
| 13be4302c3 | |||
| 5fd45d3e94 | |||
| e88b815dc9 | |||
| 556a4ffcc2 | |||
| 03c5d33eb7 | |||
| f9786aa75a | |||
| b87623cf66 | |||
| 4d24e648ab | |||
| 99f42f66b2 | |||
| 3240b0788b | |||
| 3ab1501a90 | |||
| 7e42e00449 | |||
| 51ea558e19 | |||
| 75bd3228e5 | |||
| 86e4ba7e29 | |||
| 4bbc00b765 | |||
| 32b373bf2c | |||
| cdfc10c854 | |||
| adb472956e | |||
| 60d3cff5e7 | |||
| b208576068 | |||
| 1ee64efc81 | |||
| bb4ccc6355 | |||
| 70e9611f02 | |||
| d05144bdb3 | |||
| bfd92e3883 | |||
| 3b191dccd6 | |||
| c0eda84644 | |||
| 19f382a31a | |||
| 3b205db329 | |||
| d8c5491200 | |||
| b9c8b1c730 | |||
| 684f17f507 | |||
| a0f64f6fa6 | |||
| 06c218c736 | |||
| 1e249a0eec | |||
| 249fccadd3 | |||
| 82957ab162 | |||
| e8727358eb | |||
| 28891f4709 | |||
| 297f971bd5 | |||
| 4bf35cf786 | |||
| 28e19b8898 | |||
| 4ed7969e90 | |||
| ef7d8cca9f | |||
| 1683d98b90 | |||
| ae9fe5d063 | |||
| 6783a1cbc4 | |||
| 7fd7861b4b | |||
| 3e453a7b6d | |||
| fbbf20d820 | |||
| 765404fdc2 | |||
| 67fa196a28 | |||
| 4e3f0ad800 | |||
| 4c67307951 | |||
| 18aa7fe669 | |||
| 8409a4469d | |||
| b75492ec18 | |||
| fef8db6c00 | |||
| a70502fb77 | |||
| e8a9afa221 | |||
| 8fca54e523 | |||
| 8e9588c4ff | |||
| 7bc1d5b643 | |||
| ef14f5f1a1 | |||
| 7caa7247ef | |||
| 04d9d2fd56 | |||
| b4742f205a | |||
| ff36687f53 | |||
| b860a4309b | |||
| f409222f8a | |||
| 1c051933b7 | |||
| c83a2ef56f | |||
| 6d1f8f022e | |||
| 500ddf59fe | |||
| 5e4be0c279 | |||
| 16f730db76 | |||
| 958d8d5f20 | |||
| 7b68c19dc5 | |||
| 88f71c9b5e | |||
| 780ba9c58b | |||
| 079ed216a8 | |||
| 337c25f66b | |||
| eabb6b2951 | |||
| 5d4b19aef2 | |||
| 11941bedad | |||
| 8ba47f3935 | |||
| 9dd8849b21 | |||
| 725d95c079 | |||
| c5bd13ea52 | |||
| 9ecad43f76 | |||
| 953e94da44 | |||
| 805fc69281 | |||
| d620618bb8 | |||
| 6c358fbfad | |||
| a5599eb0d0 | |||
| a8d25f9c01 | |||
| a09793b6ec | |||
| 675a3cdbfb | |||
| abc51a0dad | |||
| 24332a4e23 | |||
| ebc5754684 | |||
| 340b300aa4 | |||
| bf7026cc9f | |||
| 1b04b52509 | |||
| fca334f472 | |||
| d81d644319 | |||
| 400cf1114f | |||
| fec38adc78 | |||
| 993a7d2626 | |||
| dbe09411ac | |||
| 0afc47fcdd | |||
| 4862b285a8 | |||
| 41dd1555d7 | |||
| 0cf3a25ac6 | |||
| 3674b6e2d6 | |||
| 4c9bcb00c3 | |||
| 2067d0bf84 | |||
| c0fa59d10e | |||
| 37add84d59 | |||
| c23019b8c0 | |||
| b4edd35f5f | |||
| 812f85b9a9 | |||
| 77888b7d88 | |||
| 4a38d7512d | |||
| 5d0df18dac | |||
| d18e38800e | |||
| 76e595aaec | |||
| dfb9897fa1 | |||
| 82ad784fcb | |||
| 4bd7077d64 | |||
| 3f6b9cc5ef | |||
| 0742647571 | |||
| 33090419df | |||
| 4042d0e5f1 | |||
| d3a0b41fba | |||
| 2fefea5618 | |||
| d75f7c794f | |||
| 503b91ea87 | |||
| 43db7c309d | |||
| 6e57927409 | |||
| a404f5ded9 | |||
| f6a6aab623 | |||
| 2cfbc0addc | |||
| 07d6ef984e | |||
| 50227ccae6 | |||
| 8f3c636c61 | |||
| 42761bbdbc | |||
| 0f2eba302c | |||
| 83dd58721f | |||
| d658d0b81e | |||
| e04113628a | |||
| b1e92326b6 | |||
| 9ac63bd75f | |||
| cc5ccf75a2 |
@@ -35,6 +35,7 @@ htmlcov/
|
||||
|
||||
# Local Postgres data
|
||||
pgdata/
|
||||
pgdata.bak/
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Uncomment and set to use external storage for ADS-B history
|
||||
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||
@@ -54,3 +54,14 @@ intercept_agent_*.cfg
|
||||
# Temporary files
|
||||
/tmp/
|
||||
*.tmp
|
||||
|
||||
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
||||
data/weather_sat/
|
||||
|
||||
# SDR capture files (large IQ recordings)
|
||||
data/subghz/captures/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -2,6 +2,206 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [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
|
||||
- **RF Heatmap** - Geographic signal density visualization with Leaflet heatmap overlay
|
||||
- **SignalID** - Automatic signal classification via SigIDWiki API integration
|
||||
- **PWA Support** - Installable web app with service worker caching and manifest
|
||||
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||
- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames
|
||||
- **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls
|
||||
- App icon (SVG) for PWA and browser tab
|
||||
|
||||
### Changed
|
||||
- **WebSDR overhaul** - Improved receiver management, audio streaming, and UI
|
||||
- **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown
|
||||
- **Mode transitions** - Smoother navigation with performance instrumentation
|
||||
- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing
|
||||
- **Listening Post** - Refactored with cross-module frequency routing
|
||||
- **SSTV decoder** - State machine improvements and partial image streaming
|
||||
- Analytics mode removed; per-mode analytics panels integrated into existing dashboards
|
||||
|
||||
### Fixed
|
||||
- ADS-B SSE multi-client fanout stability and update flush timing
|
||||
- WiFi scanner robustness and monitor mode teardown reliability
|
||||
- Agent client reliability improvements for remote sensor nodes
|
||||
- SSTV VIS detector state reporting in signal monitor diagnostics
|
||||
|
||||
### Documentation
|
||||
- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages
|
||||
- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file
|
||||
- Fixed tool name `rtl_amr` → `rtlamr` throughout all docs
|
||||
- Fixed incorrect entry point examples (`python app.py` → `sudo -E venv/bin/python intercept.py`)
|
||||
- Removed duplicate AIS Vessel Tracking section from FEATURES.md
|
||||
- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed
|
||||
- Added ACARS and VDL2 mode descriptions to in-app help modal
|
||||
- GitHub Pages site: corrected Docker command, license, and tool name references
|
||||
|
||||
---
|
||||
|
||||
## [2.21.1] - 2026-02-20
|
||||
|
||||
### Fixed
|
||||
- BT Locate map first-load rendering race that could cause blank/late map initialization
|
||||
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
|
||||
- BT Locate trail restore startup latency by batching historical GPS point rendering
|
||||
|
||||
---
|
||||
|
||||
## [2.21.0] - 2026-02-20
|
||||
|
||||
### Added
|
||||
- Analytics panels for operational insights and temporal pattern analysis
|
||||
|
||||
### Changed
|
||||
- Global map theme refresh with improved contrast and cross-dashboard consistency
|
||||
- Cross-app UX refinements for accessibility, mode consistency, and render performance
|
||||
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
|
||||
|
||||
### Fixed
|
||||
- Weather satellite auto-scheduler and Mercator tracking reliability issues
|
||||
- Bluetooth/WiFi runtime health issues affecting scanner continuity
|
||||
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
|
||||
|
||||
---
|
||||
|
||||
## [2.15.0] - 2026-02-09
|
||||
|
||||
### Added
|
||||
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
||||
- Click-to-tune, zoom controls, and auto-scaling quantization
|
||||
- Shared waterfall UI across SDR modes with function bar controls
|
||||
- WebSocket frame serialization and connection reuse
|
||||
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
|
||||
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
|
||||
- Real-time decode progress with partial image streaming
|
||||
- VIS detector state in signal monitor diagnostics
|
||||
- Image gallery with delete and download functionality
|
||||
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||
- **SSTV Image Gallery** - Delete and download decoded images
|
||||
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
|
||||
|
||||
### Fixed
|
||||
- DMR dsd-fme protocol flags, device label, and tuning controls
|
||||
- DMR frontend/backend state desync causing 409 on start
|
||||
- Digital voice decoder producing no output due to wrong dsd-fme flags
|
||||
- SDR device lock-up from unreleased device registry on process crash
|
||||
- APRS crash on large station count and station list overflow
|
||||
- Settings modal overflowing viewport on smaller screens
|
||||
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
|
||||
- PD120 SSTV decode hang and false leader tone detection
|
||||
- WebSocket waterfall blocked by login redirect
|
||||
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
|
||||
|
||||
### Removed
|
||||
- GSM Spy functionality removed for legal compliance
|
||||
|
||||
---
|
||||
|
||||
## [2.14.0] - 2026-02-06
|
||||
|
||||
### Added
|
||||
- **DMR Digital Voice Decoder** - Decode DMR, P25, NXDN, and D-STAR protocols
|
||||
- Integration with dsd-fme (Digital Speech Decoder - Florida Man Edition)
|
||||
- Real-time SSE streaming of sync, call, voice, and slot events
|
||||
- Call history table with talkgroup, source ID, and protocol tracking
|
||||
- Protocol auto-detection or manual selection
|
||||
- Pipeline error diagnostics with rtl_fm stderr capture
|
||||
- **DMR Visual Synthesizer** - Canvas-based signal activity visualization
|
||||
- Spring-physics animated bars reacting to SSE decoder events
|
||||
- Color-coded by event type: cyan (sync), green (call), orange (voice)
|
||||
- Center-outward ripple bursts on sync events
|
||||
- Smooth decay and idle breathing animation
|
||||
- Responsive canvas with window resize handling
|
||||
- **HF SSTV General Mode** - Terrestrial slow-scan TV on shortwave frequencies
|
||||
- Predefined HF SSTV frequencies (14.230, 21.340, 28.680 MHz, etc.)
|
||||
- Modulation support for USB/LSB reception
|
||||
- **WebSDR Integration** - Remote HF/shortwave listening via WebSDR servers
|
||||
- **Listening Post Enhancements** - Improved signal scanner and audio handling
|
||||
|
||||
### Fixed
|
||||
- APRS rtl_fm startup failure and SDR device conflicts
|
||||
- DSD voice decoder detection for dsd-fme and PulseAudio errors
|
||||
- dsd-fme protocol flags and ncurses disable for headless operation
|
||||
- dsd-fme audio output flag for pipeline compatibility
|
||||
- TSCM sweep scan resilience with per-device error isolation
|
||||
- TSCM WiFi detection using scanner singleton for device availability
|
||||
- TSCM correlation and cluster emission fixes
|
||||
- Detected Threats panel items now clickable to show device details
|
||||
- Proximity radar tooltip flicker on hover
|
||||
- Radar blip flicker by deferring renders during hover
|
||||
- ISS position API priority swap to avoid timeout delays
|
||||
- Updater settings panel error when updater.js is blocked
|
||||
- Missing scapy in optionals dependency group
|
||||
|
||||
---
|
||||
|
||||
## [2.13.1] - 2026-02-04
|
||||
|
||||
### Added
|
||||
- **UI Overhaul** - Revamped styling with slate/cyan theme
|
||||
- Switched app font to JetBrains Mono
|
||||
- Global navigation bar across all dashboards
|
||||
- Cyan-tinted map tiles as default
|
||||
- **Signal Scanner Rewrite** - Switched to rtl_power sweep for better coverage
|
||||
- SNR column added to signal hits table
|
||||
- SNR threshold control for power scan
|
||||
- Improved sweep progress tracking and stability
|
||||
- Frequency-based sweep display with range syncing
|
||||
- **Listening Post Audio** - WAV streaming with retry and fallback
|
||||
- WebSocket audio fallback for listening
|
||||
- User-initiated audio play prompt
|
||||
- Audio pipeline restart for fresh stream headers
|
||||
|
||||
### Fixed
|
||||
- WiFi connected clients panel now filters to selected AP instead of showing all clients
|
||||
- USB device contention when starting audio pipeline
|
||||
- Dual scrollbar issue on main dashboard
|
||||
- Controls bar alignment in dashboard pages
|
||||
- Mode query routing from dashboard nav
|
||||
|
||||
---
|
||||
|
||||
## [2.13.0] - 2026-02-04
|
||||
|
||||
### Added
|
||||
- **WiFi Client Display** - Connected clients shown in AP detail drawer
|
||||
- Real-time client updates via SSE streaming
|
||||
- Probed SSID badges for connected clients
|
||||
- Signal strength indicators and vendor identification
|
||||
- **Help Modal** - Keyboard shortcuts reference system
|
||||
- **Main Dashboard Button** - Quick navigation from any page
|
||||
- **Settings Modal** - Accessible from all dashboards
|
||||
|
||||
### Changed
|
||||
- Dashboard CSS improvements and consistency fixes
|
||||
|
||||
---
|
||||
|
||||
## [2.12.1] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- **SDR Device Registry** - Prevents decoder conflicts between concurrent modes
|
||||
- **SDR Device Status Panel** - Shows connected SDR devices with ADS-B Bias-T toggle
|
||||
- **Real-time Doppler Tracking** - ISS SSTV reception with Doppler correction
|
||||
- **TCP Connection Support** - Meshtastic devices connectable over TCP
|
||||
- **Shared Observer Location** - Configurable shared location with auto-start options
|
||||
- **slowrx Source Build** - Fallback build for Debian/Ubuntu
|
||||
|
||||
### Fixed
|
||||
- SDR device type not synced on page refresh
|
||||
- Meshtastic connection type not restored on page refresh
|
||||
- WiFi deep scan polling on agent with normalized scan_type value
|
||||
- Auto-detect RTL-SDR drivers and blacklist instead of prompting
|
||||
- TPMS pressure field mappings for 433MHz sensor display
|
||||
- Agent capabilities cache invalidation after monitor mode toggle
|
||||
|
||||
---
|
||||
|
||||
## [2.12.0] - 2026-01-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,11 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking.
|
||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Setup and Running
|
||||
### Docker (Primary)
|
||||
```bash
|
||||
# Build and run (basic profile)
|
||||
docker compose --profile basic up -d
|
||||
|
||||
# Build and run with ADS-B history (Postgres)
|
||||
docker compose --profile history up -d
|
||||
|
||||
# Rebuild after code changes
|
||||
docker compose --profile basic up -d --build
|
||||
|
||||
# Multi-arch build (amd64 + arm64 for RPi)
|
||||
./build-multiarch.sh
|
||||
```
|
||||
|
||||
### Local Setup (Alternative)
|
||||
```bash
|
||||
# Initial setup (installs dependencies and configures SDR tools)
|
||||
./setup.sh
|
||||
@@ -66,8 +81,12 @@ Each signal type has its own Flask blueprint:
|
||||
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||
- `satellite.py` - Pass prediction using TLE data
|
||||
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||
- `aprs.py` - Amateur packet radio via direwolf
|
||||
- `rtlamr.py` - Utility meter reading
|
||||
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||
|
||||
### Core Utilities (utils/)
|
||||
|
||||
@@ -91,6 +110,15 @@ Each signal type has its own Flask blueprint:
|
||||
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||
- `channel_analyzer.py` - Frequency band analysis
|
||||
|
||||
**Weather Satellite** (`utils/weather_sat.py`):
|
||||
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||
- Subprocess management with stdout parsing, image watcher via rglob
|
||||
- Pass prediction using skyfield TLE data
|
||||
|
||||
**SSTV Decoder** (`utils/sstv.py`):
|
||||
- ISS SSTV reception via slowrx with Doppler tracking
|
||||
- Singleton pattern, image gallery with timestamped filenames
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
||||
@@ -112,9 +140,25 @@ Each signal type has its own Flask blueprint:
|
||||
| acarsdec | ACARS messages | Output parsing |
|
||||
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||
| direwolf | APRS | TNC modem for packet radio |
|
||||
|
||||
### Frontend Structure
|
||||
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
|
||||
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||
- Data persisted via `./data:/app/data` volume mount
|
||||
|
||||
### Configuration
|
||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix
|
||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
||||
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||
|
||||
## Testing Notes
|
||||
|
||||
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||
|
||||
# Install system dependencies for SDR tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# RTL-SDR tools
|
||||
@@ -21,6 +24,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
multimon-ng \
|
||||
# Audio tools for Listening Post
|
||||
ffmpeg \
|
||||
# SSTV decoder runtime libs
|
||||
libsndfile1 \
|
||||
# SatDump runtime libs (weather satellite decoding)
|
||||
libpng16-16 \
|
||||
libtiff6 \
|
||||
libjemalloc2 \
|
||||
libvolk-bin \
|
||||
libnng1 \
|
||||
libzstd1 \
|
||||
# WiFi tools (aircrack-ng suite)
|
||||
aircrack-ng \
|
||||
iw \
|
||||
@@ -29,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bluez \
|
||||
bluetooth \
|
||||
# GPS support
|
||||
gpsd \
|
||||
gpsd-clients \
|
||||
# Utilities
|
||||
# APRS
|
||||
@@ -41,8 +54,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
soapysdr-module-rtlsdr \
|
||||
soapysdr-module-hackrf \
|
||||
soapysdr-module-lms7 \
|
||||
soapysdr-module-airspy \
|
||||
airspy \
|
||||
limesuite \
|
||||
hackrf \
|
||||
# Utilities
|
||||
curl \
|
||||
procps \
|
||||
@@ -56,13 +70,31 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
# GTK is required for slowrx (SSTV decoder GUI dependency).
|
||||
# Note: slowrx is kept for backwards compatibility, but the pure Python
|
||||
# SSTV decoder in utils/sstv/ is now the primary implementation.
|
||||
# GTK can be removed if slowrx is deprecated in future releases.
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libfftw3-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libglib2.0-dev \
|
||||
libxml2-dev \
|
||||
# Build dump1090
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||
@@ -105,11 +137,79 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||
&& cd acarsdec \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. -Drtl=ON \
|
||||
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
&& make \
|
||||
&& cp acarsdec /usr/bin/acarsdec \
|
||||
&& rm -rf /tmp/acarsdec \
|
||||
# Build libacars (required by dumpvdl2)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
||||
&& cd libacars \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/libacars \
|
||||
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
||||
&& cd dumpvdl2 \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
|
||||
&& rm -rf /tmp/dumpvdl2 \
|
||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/windytan/slowrx.git \
|
||||
&& cd slowrx \
|
||||
&& git checkout ca6d7012 \
|
||||
&& make \
|
||||
&& install -m 0755 slowrx /usr/local/bin/slowrx \
|
||||
&& rm -rf /tmp/slowrx \
|
||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||
&& cd SatDump \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
|
||||
&& mkdir -p /usr/local/lib/satdump/plugins \
|
||||
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
|
||||
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
|
||||
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
|
||||
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
fi \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/SatDump \
|
||||
# Build 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 \
|
||||
# Build rtlamr (utility meter decoder - requires Go)
|
||||
&& cd /tmp \
|
||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||
&& export PATH="$PATH:/usr/local/go/bin" \
|
||||
&& export GOPATH=/tmp/gopath \
|
||||
&& go install github.com/bemasher/rtlamr@latest \
|
||||
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||
&& rm -rf /usr/local/go /tmp/gopath \
|
||||
# Cleanup build tools to reduce image size
|
||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||
&& apt-get remove -y \
|
||||
build-essential \
|
||||
git \
|
||||
@@ -117,6 +217,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
@@ -124,6 +232,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -135,7 +246,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data
|
||||
RUN mkdir -p /app/data /app/data/weather_sat
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
|
||||
@@ -1,21 +1,200 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 smittix
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Definitions.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an OpenPGP
|
||||
key and encrypt outgoing communications.
|
||||
|
||||
Copyright 2025 smittix
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
||||
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||
</p>
|
||||
|
||||
@@ -28,15 +28,27 @@ Support the developer of this open-source project
|
||||
|
||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||
- **Listening Post** - Frequency scanner with audio monitoring
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
|
||||
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
||||
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
||||
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
||||
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
@@ -45,8 +57,6 @@ Support the developer of this open-source project
|
||||
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
|
||||
```
|
||||
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
@@ -55,73 +65,112 @@ cd intercept
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Docker (Alternative)
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker compose up -d
|
||||
docker compose --profile basic up -d --build
|
||||
```
|
||||
|
||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||
|
||||
### ADS-B History (Optional)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
|
||||
```bash
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Set the following environment variables (for example in a `.env` file):
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||
INTERCEPT_ADSB_DB_PORT=5432
|
||||
INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||
INTERCEPT_ADSB_DB_USER=intercept
|
||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
```
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
Set these as environment variables for either local installs or Docker:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
```
|
||||
|
||||
Then open **/adsb/history** for the reporting dashboard.
|
||||
> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
|
||||
|
||||
#### Multi-Architecture Builds (amd64 + arm64)
|
||||
|
||||
Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
|
||||
|
||||
```bash
|
||||
# One-time setup on your x64 build machine
|
||||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
docker buildx create --name intercept-builder --use --bootstrap
|
||||
|
||||
# Build and push for both architectures
|
||||
REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
|
||||
|
||||
# On the RPi5, just pull and run
|
||||
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
|
||||
```
|
||||
|
||||
Build script options:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--push` | Push to container registry |
|
||||
| `--load` | Load into local Docker (single platform only) |
|
||||
| `--arm64-only` | Build arm64 only (for RPi deployment) |
|
||||
| `--amd64-only` | Build amd64 only |
|
||||
|
||||
Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
|
||||
|
||||
#### Using a Pre-built Image
|
||||
|
||||
If you've pushed to a registry, you can skip building entirely on the target machine:
|
||||
|
||||
```bash
|
||||
# Set in .env or export
|
||||
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
||||
|
||||
# Then just run
|
||||
docker compose --profile basic up -d
|
||||
```
|
||||
|
||||
### ADS-B History (Optional)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
|
||||
```bash
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Set the following environment variables (for example in a `.env` file):
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||
INTERCEPT_ADSB_DB_PORT=5432
|
||||
INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||
INTERCEPT_ADSB_DB_USER=intercept
|
||||
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
```
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
Set these as environment variables for either local installs or Docker:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
```
|
||||
|
||||
Then open **/adsb/history** for the reporting dashboard.
|
||||
|
||||
### Open the Interface
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -179,7 +228,7 @@ This project was developed using AI as a coding partner, combining human directi
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
Apache 2.0 License - see [LICENSE](LICENSE)
|
||||
|
||||
## Author
|
||||
|
||||
@@ -193,8 +242,12 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[dump1090](https://github.com/flightaware/dump1090) |
|
||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||
[rtlamr](https://github.com/bemasher/rtlamr) |
|
||||
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[SatDump](https://github.com/SatDump/SatDump) |
|
||||
[Celestrak](https://celestrak.org/) |
|
||||
[Priyom.org](https://priyom.org/)
|
||||
|
||||
@@ -206,3 +259,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-11_fae1348c",
|
||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
||||
"version": "2026-02-15_ae16bb62",
|
||||
"downloaded": "2026-02-20T00:29:06.228007Z"
|
||||
}
|
||||
@@ -25,11 +25,11 @@ import subprocess
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes
|
||||
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
||||
from utils.sdr import SDRFactory
|
||||
from utils.cleanup import DataStore, cleanup_manager
|
||||
from utils.constants import (
|
||||
@@ -100,12 +100,25 @@ def add_security_headers(response):
|
||||
def inject_offline_settings():
|
||||
"""Inject offline settings into all templates."""
|
||||
from utils.database import get_setting
|
||||
|
||||
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
||||
# third-party tracker/storage defenses in strict browsers.
|
||||
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
|
||||
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
|
||||
if assets_source not in ('local', 'cdn'):
|
||||
assets_source = 'local'
|
||||
if fonts_source not in ('local', 'cdn'):
|
||||
fonts_source = 'local'
|
||||
# Force local delivery for core dashboard pages.
|
||||
assets_source = 'local'
|
||||
fonts_source = 'local'
|
||||
|
||||
return {
|
||||
'offline_settings': {
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
||||
'assets_source': assets_source,
|
||||
'fonts_source': fonts_source,
|
||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
}
|
||||
@@ -150,6 +163,11 @@ acars_process = None
|
||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
acars_lock = threading.Lock()
|
||||
|
||||
# VDL2 aircraft datalink
|
||||
vdl2_process = None
|
||||
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
vdl2_lock = threading.Lock()
|
||||
|
||||
# APRS amateur radio tracking
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
@@ -176,6 +194,10 @@ dsc_lock = threading.Lock()
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
|
||||
# SubGHz Transceiver (HackRF)
|
||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
subghz_lock = threading.Lock()
|
||||
|
||||
# Deauth Attack Detection
|
||||
deauth_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
@@ -238,6 +260,10 @@ sdr_device_registry_lock = threading.Lock()
|
||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
"""Claim an SDR device for a mode.
|
||||
|
||||
Checks the in-app registry first, then probes the USB device to
|
||||
catch stale handles held by external processes (e.g. a leftover
|
||||
rtl_fm from a previous crash).
|
||||
|
||||
Args:
|
||||
device_index: The SDR device index to claim
|
||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||
@@ -249,6 +275,16 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
if device_index in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[device_index]
|
||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||
|
||||
# Probe the USB device to catch external processes holding the handle
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
|
||||
sdr_device_registry[device_index] = mode_name
|
||||
return None
|
||||
|
||||
@@ -282,6 +318,14 @@ def require_login():
|
||||
# Routes that don't require login (to avoid infinite redirect loop)
|
||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||
|
||||
# Allow audio streaming endpoints without session auth
|
||||
if request.path.startswith('/listening/audio/'):
|
||||
return None
|
||||
|
||||
# Allow WebSocket upgrade requests (page load already required auth)
|
||||
if request.path.startswith('/ws/'):
|
||||
return None
|
||||
|
||||
# Controller API endpoints use API key auth, not session auth
|
||||
# Allow agent push/pull endpoints without session login
|
||||
if request.path.startswith('/controller/'):
|
||||
@@ -342,6 +386,8 @@ def index() -> str:
|
||||
version=VERSION,
|
||||
changelog=CHANGELOG,
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
default_latitude=DEFAULT_LATITUDE,
|
||||
default_longitude=DEFAULT_LONGITUDE,
|
||||
)
|
||||
|
||||
|
||||
@@ -350,6 +396,18 @@ def favicon() -> Response:
|
||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||
|
||||
|
||||
@app.route('/sw.js')
|
||||
def service_worker() -> Response:
|
||||
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
||||
resp.headers['Service-Worker-Allowed'] = '/'
|
||||
return resp
|
||||
|
||||
|
||||
@app.route('/manifest.json')
|
||||
def pwa_manifest() -> Response:
|
||||
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
||||
|
||||
|
||||
@app.route('/devices')
|
||||
def get_devices() -> Response:
|
||||
"""Get all detected SDR devices with hardware type info."""
|
||||
@@ -613,10 +671,75 @@ def export_bluetooth() -> Response:
|
||||
})
|
||||
|
||||
|
||||
def _get_subghz_active() -> bool:
|
||||
"""Check if SubGHz manager has an active process."""
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
return get_subghz_manager().active_mode != 'idle'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||
"""Return Bluetooth active state and best-effort device count."""
|
||||
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||
scanner_running = False
|
||||
scanner_count = 0
|
||||
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None:
|
||||
scanner_running = bool(bt_scanner.is_scanning)
|
||||
scanner_count = int(bt_scanner.device_count)
|
||||
except Exception:
|
||||
scanner_running = False
|
||||
scanner_count = 0
|
||||
|
||||
locate_running = False
|
||||
try:
|
||||
from utils.bt_locate import get_locate_session
|
||||
session = get_locate_session()
|
||||
if session and getattr(session, 'active', False):
|
||||
scanner = getattr(session, '_scanner', None)
|
||||
locate_running = bool(scanner and scanner.is_scanning)
|
||||
except Exception:
|
||||
locate_running = False
|
||||
|
||||
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
||||
|
||||
|
||||
def _get_wifi_health() -> tuple[bool, int, int]:
|
||||
"""Return WiFi active state and best-effort network/client counts."""
|
||||
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
||||
scanner_running = False
|
||||
scanner_networks = 0
|
||||
scanner_clients = 0
|
||||
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
status = wifi_scanner.get_status()
|
||||
scanner_running = bool(status.is_scanning)
|
||||
scanner_networks = int(status.networks_found or 0)
|
||||
scanner_clients = int(status.clients_found or 0)
|
||||
except Exception:
|
||||
scanner_running = False
|
||||
scanner_networks = 0
|
||||
scanner_clients = 0
|
||||
|
||||
return (
|
||||
legacy_running or scanner_running,
|
||||
max(len(wifi_networks), scanner_networks),
|
||||
max(len(wifi_clients), scanner_clients),
|
||||
)
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import time
|
||||
bt_active, bt_device_count = _get_bluetooth_health()
|
||||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'version': VERSION,
|
||||
@@ -627,17 +750,19 @@ def health_check() -> Response:
|
||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
|
||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'wifi': wifi_active,
|
||||
'bluetooth': bt_active,
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'subghz': _get_subghz_active(),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'vessel_count': len(ais_vessels),
|
||||
'wifi_networks_count': len(wifi_networks),
|
||||
'wifi_clients_count': len(wifi_clients),
|
||||
'bt_devices_count': len(bt_devices),
|
||||
'wifi_networks_count': wifi_network_count,
|
||||
'wifi_clients_count': wifi_client_count,
|
||||
'bt_devices_count': bt_device_count,
|
||||
'dsc_messages_count': len(dsc_messages),
|
||||
}
|
||||
})
|
||||
@@ -645,19 +770,24 @@ def health_check() -> Response:
|
||||
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder and WiFi processes."""
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
|
||||
global vdl2_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
from utils.bluetooth import reset_bluetooth_scanner
|
||||
|
||||
killed = []
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
|
||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump',
|
||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||
'hackrf_transfer', 'hackrf_sweep'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -691,6 +821,10 @@ def kill_all() -> Response:
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
|
||||
# Reset VDL2 state
|
||||
with vdl2_lock:
|
||||
vdl2_process = None
|
||||
|
||||
# Reset APRS state
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
@@ -701,6 +835,33 @@ def kill_all() -> Response:
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
|
||||
# Reset Bluetooth state (legacy)
|
||||
with bt_lock:
|
||||
if bt_process:
|
||||
try:
|
||||
bt_process.terminate()
|
||||
bt_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
bt_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
bt_process = None
|
||||
|
||||
# Reset Bluetooth v2 scanner
|
||||
try:
|
||||
reset_bluetooth_scanner()
|
||||
killed.append('bluetooth')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset SubGHz state
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
get_subghz_manager().stop_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear SDR device registry
|
||||
with sdr_device_registry_lock:
|
||||
sdr_device_registry.clear()
|
||||
@@ -785,11 +946,24 @@ def main() -> None:
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
cleanup_stale_dump1090()
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
|
||||
# Register database cleanup functions
|
||||
from utils.database import (
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||
|
||||
# Start automatic cleanup of stale data entries
|
||||
cleanup_manager.start()
|
||||
|
||||
@@ -797,6 +971,15 @@ def main() -> None:
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
import os
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
# Update TLE data in background thread (non-blocking)
|
||||
def update_tle_background():
|
||||
try:
|
||||
@@ -821,6 +1004,22 @@ def main() -> None:
|
||||
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("Press Ctrl+C to stop")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/bin/bash
|
||||
# INTERCEPT - Multi-architecture Docker image builder
|
||||
#
|
||||
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
||||
# Run this on your x64 machine to cross-compile the arm64 image
|
||||
# instead of building natively on the RPi5.
|
||||
#
|
||||
# Prerequisites (one-time setup):
|
||||
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
# docker buildx create --name intercept-builder --use --bootstrap
|
||||
#
|
||||
# Usage:
|
||||
# ./build-multiarch.sh # Build both platforms, load locally
|
||||
# ./build-multiarch.sh --push # Build and push to registry
|
||||
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
||||
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Container registry (default: docker.io/library)
|
||||
# IMAGE_NAME - Image name (default: intercept)
|
||||
# IMAGE_TAG - Image tag (default: latest)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
BUILDER_NAME="intercept-builder"
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
# Parse arguments
|
||||
PUSH=false
|
||||
LOAD=false
|
||||
ARM64_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--push) PUSH=true ;;
|
||||
--load) LOAD=true ;;
|
||||
--arm64-only)
|
||||
ARM64_ONLY=true
|
||||
PLATFORMS="linux/arm64"
|
||||
;;
|
||||
--amd64-only)
|
||||
PLATFORMS="linux/amd64"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --push Push to container registry"
|
||||
echo " --load Load into local Docker (single platform only)"
|
||||
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
||||
echo " --amd64-only Build amd64 only"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
||||
echo " IMAGE_NAME Image name (default: intercept)"
|
||||
echo " IMAGE_TAG Image tag (default: latest)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 --push # Build both, push"
|
||||
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
||||
echo " $0 --arm64-only --load # Build arm64, load locally"
|
||||
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Build full image reference
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo " INTERCEPT Multi-Architecture Builder"
|
||||
echo "============================================"
|
||||
echo " Image: ${FULL_IMAGE}"
|
||||
echo " Platforms: ${PLATFORMS}"
|
||||
echo " Push: ${PUSH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Check if buildx builder exists, create if not
|
||||
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||
echo "Creating buildx builder: ${BUILDER_NAME}"
|
||||
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||
|
||||
# Check for QEMU support
|
||||
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
||||
echo "WARNING: QEMU binfmt setup may have failed."
|
||||
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
||||
fi
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
|
||||
# Build command
|
||||
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
||||
|
||||
if [ "$PUSH" = true ]; then
|
||||
BUILD_CMD="${BUILD_CMD} --push"
|
||||
echo "Will push to: ${FULL_IMAGE}"
|
||||
elif [ "$LOAD" = true ]; then
|
||||
# --load only works with single platform
|
||||
if echo "$PLATFORMS" | grep -q ","; then
|
||||
echo "ERROR: --load only works with a single platform."
|
||||
echo "Use --arm64-only or --amd64-only with --load."
|
||||
exit 1
|
||||
fi
|
||||
BUILD_CMD="${BUILD_CMD} --load"
|
||||
echo "Will load into local Docker"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building..."
|
||||
echo "Command: ${BUILD_CMD} ."
|
||||
echo ""
|
||||
|
||||
$BUILD_CMD .
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Build complete!"
|
||||
if [ "$PUSH" = true ]; then
|
||||
echo " Image pushed to: ${FULL_IMAGE}"
|
||||
echo ""
|
||||
echo " Pull on RPi5:"
|
||||
echo " docker pull ${FULL_IMAGE}"
|
||||
fi
|
||||
echo "============================================"
|
||||
@@ -7,15 +7,162 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.12.1"
|
||||
VERSION = "2.22.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.22.0",
|
||||
"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",
|
||||
"RF Heatmap for geographic signal density visualization",
|
||||
"SignalID integration via SigIDWiki API for automatic signal classification",
|
||||
"PWA support: installable web app with service worker and manifest",
|
||||
"Mode stop responsiveness improvements with faster timeout handling",
|
||||
"Navigation performance instrumentation and smoother mode transitions",
|
||||
"Pager, sensor, and SSTV real-time signal scope visualization",
|
||||
"ADS-B MSG2 surface movement parsing for ground vehicle tracking",
|
||||
"WebSDR major overhaul with improved receiver management and audio streaming",
|
||||
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
|
||||
"Help modal updated with ACARS and VDL2 mode descriptions",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.21.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"BT Locate map first-load fix with render stabilization retries during initial mode open",
|
||||
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
|
||||
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.21.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Global map theme refresh with improved contrast and cross-dashboard consistency",
|
||||
"Cross-app UX updates for accessibility, mode consistency, and render performance",
|
||||
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
|
||||
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
||||
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
||||
"Analytics enhancements with operational insights and temporal pattern panels",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.20.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
||||
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
||||
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
||||
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
||||
"No SDR hardware required — all data from public APIs with server-side caching",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.19.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
|
||||
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
|
||||
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
|
||||
"Help modal updated with all modes and correct SVG icons",
|
||||
"Setup script overhauled for reliability and macOS compatibility",
|
||||
"GPS fix for preserving satellites across DOP-only SKY messages",
|
||||
"Fix gpsd deadlock causing GPS connect to hang",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.18.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
|
||||
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
|
||||
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
|
||||
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
||||
"GPS: error state indicator and UI refinements",
|
||||
"Proximity radar and signal card UI improvements",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.17.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
|
||||
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
|
||||
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
|
||||
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.16.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
|
||||
"Weather satellite auto-scheduler with polar plot and ground track map",
|
||||
"SatDump support for local (non-Docker) installs via setup.sh",
|
||||
"Shared waterfall UI across SDR modes",
|
||||
"Listening post audio stuttering fix and SDR race condition fixes",
|
||||
"Multi-arch Docker build support (amd64 + arm64)",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.15.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
|
||||
"Cross-module frequency routing from Listening Post to decoders",
|
||||
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||
"SDR device lock-up fix from unreleased device registry on crash",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.14.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"HF SSTV general mode with predefined shortwave frequencies",
|
||||
"WebSDR integration for remote HF/shortwave listening",
|
||||
"Listening Post signal scanner and audio pipeline improvements",
|
||||
"TSCM sweep resilience, WiFi detection, and correlation fixes",
|
||||
"APRS rtl_fm startup and SDR device conflict fixes",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.13.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"UI overhaul with slate/cyan theme and JetBrains Mono font",
|
||||
"Signal scanner rewritten with rtl_power sweep and SNR filtering",
|
||||
"Listening Post audio streaming via WAV with retry/fallback",
|
||||
"WiFi connected clients panel now filters to selected AP",
|
||||
"Global navigation bar across all dashboards",
|
||||
"Fixed USB device contention when starting audio pipeline",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.13.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"WiFi client display in AP detail drawer with real-time SSE updates",
|
||||
"Help modal system with keyboard shortcuts reference",
|
||||
"Global navbar and settings modal accessible from all dashboards",
|
||||
"Probed SSID badges for connected clients",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.12.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Bug fixes and improvements",
|
||||
"SDR device registry to prevent decoder conflicts",
|
||||
"SDR device status panel and ADS-B Bias-T toggle",
|
||||
"Real-time Doppler tracking for ISS SSTV reception",
|
||||
"TCP connection support for Meshtastic",
|
||||
"Shared observer location with auto-start options",
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -159,21 +306,47 @@ ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||
|
||||
# Observer location settings
|
||||
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
||||
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
|
||||
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
|
||||
|
||||
# Satellite settings
|
||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
# Weather satellite settings
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
|
||||
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
|
||||
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
|
||||
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
|
||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
|
||||
|
||||
# SubGHz transceiver settings (HackRF)
|
||||
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
|
||||
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
|
||||
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
|
||||
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
|
||||
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
|
||||
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
|
||||
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
|
||||
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
|
||||
|
||||
# Update checking
|
||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||
|
||||
# Alerting
|
||||
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
||||
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
||||
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||
|
||||
# Admin credentials
|
||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure application logging."""
|
||||
logging.basicConfig(
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker Compose configuration for easy deployment
|
||||
#
|
||||
# Basic usage:
|
||||
# docker compose up -d
|
||||
# Basic usage (build locally):
|
||||
# docker compose --profile basic up -d --build
|
||||
#
|
||||
# Basic usage (pre-built image from registry):
|
||||
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
||||
#
|
||||
# With ADS-B history (Postgres):
|
||||
# docker compose --profile history up -d
|
||||
|
||||
services:
|
||||
intercept:
|
||||
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
build: .
|
||||
container_name: intercept
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Privileged mode required for USB SDR device access
|
||||
# Alternatively, use device mapping (see below)
|
||||
privileged: true
|
||||
# USB device mapping (alternative to privileged mode)
|
||||
# devices:
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
# volumes:
|
||||
# Persist data directory
|
||||
# - ./data:/app/data
|
||||
# USB device mapping for all USB devices
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
# Persist decoded images and database across container rebuilds
|
||||
- ./data:/app/data
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
@@ -40,6 +44,9 @@ services:
|
||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||
# Shared observer location across modules
|
||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||
# Network mode for WiFi scanning (requires host network)
|
||||
# network_mode: host
|
||||
restart: unless-stopped
|
||||
@@ -53,15 +60,23 @@ services:
|
||||
# ADS-B history with Postgres persistence
|
||||
# Enable with: docker compose --profile history up -d
|
||||
intercept-history:
|
||||
# Same image/build fallback pattern as above
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
build: .
|
||||
container_name: intercept
|
||||
container_name: intercept-history
|
||||
profiles:
|
||||
- history
|
||||
depends_on:
|
||||
- adsb_db
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Privileged mode required for USB SDR device access
|
||||
privileged: true
|
||||
# USB device mapping for all USB devices
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
@@ -76,6 +91,9 @@ services:
|
||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||
# Shared observer location across modules
|
||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||
@@ -94,7 +112,8 @@ services:
|
||||
- POSTGRES_USER=intercept
|
||||
- POSTGRES_PASSWORD=intercept
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
# Default local path (override with PGDATA_PATH for external storage)
|
||||
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
www.intercept-sigint.com
|
||||
@@ -16,16 +16,13 @@ Complete feature list for all modules.
|
||||
- **Doorbells, remotes, and IoT devices**
|
||||
- **Smart meters** and utility monitors
|
||||
|
||||
## AIS Vessel Tracking
|
||||
## Sub-GHz Analyzer
|
||||
|
||||
- **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
|
||||
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
|
||||
- **Protocol decoding** - identify and decode common Sub-GHz protocols
|
||||
- **Signal replay/transmit** capabilities for authorized testing
|
||||
- **Wideband spectrum analysis** with real-time visualization
|
||||
- **I/Q capture** - record raw samples for offline analysis
|
||||
|
||||
## Spy Stations (Number Stations)
|
||||
|
||||
@@ -84,6 +81,95 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||
|
||||
## ACARS Messaging
|
||||
|
||||
- **Real-time ACARS decoding** via acarsdec
|
||||
- **Aircraft datalink messages** - operational, weather, and position reports
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
- **Message filtering** - filter by message type, flight, or registration
|
||||
|
||||
## VDL2 (VHF Data Link Mode 2)
|
||||
|
||||
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
|
||||
- **ACARS-over-AVLC** message capture with full frame parsing
|
||||
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
|
||||
- **AVLC frame details** - source/destination addresses, frame type, command/response
|
||||
- **Raw JSON inspection** - expandable raw message data for each frame
|
||||
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
- **CSV/JSON export** - export captured messages for offline analysis
|
||||
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
||||
|
||||
## Listening Post
|
||||
|
||||
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||
- **Cross-module frequency routing** from scanner to decoders
|
||||
- **Customizable frequency presets** and band bookmarks
|
||||
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||
|
||||
## Weather Satellites
|
||||
|
||||
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
|
||||
- **Auto-scheduler** with pass prediction and automatic capture
|
||||
- **Polar plot** - real-time satellite position on azimuth/elevation display
|
||||
- **Ground track map** - orbit path with past/future trajectory
|
||||
- **Image gallery** with timestamped decoded imagery
|
||||
|
||||
## WebSDR
|
||||
|
||||
- **KiwiSDR network integration** for remote HF/shortwave listening
|
||||
- **WebSocket audio streaming** from remote receivers
|
||||
- **Receiver discovery** with automatic caching
|
||||
- **Frequency tuning** with band presets
|
||||
|
||||
## ISS SSTV
|
||||
|
||||
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
|
||||
- **Real-time ISS tracking** with world map and pass predictions
|
||||
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
|
||||
- **Next pass countdown** - time remaining until ISS is overhead
|
||||
- **Image gallery** with timestamped decoded imagery
|
||||
- **TLE updates** - fetch latest ISS orbital elements
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## HF SSTV
|
||||
|
||||
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
|
||||
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
|
||||
- **Image gallery** with decoded transmissions
|
||||
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
|
||||
|
||||
## APRS
|
||||
|
||||
- **Amateur packet radio** position reports and telemetry via direwolf
|
||||
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
|
||||
- **Real-time position tracking** on interactive map
|
||||
- **Message and telemetry display** from APRS network
|
||||
|
||||
## Utility Meter Reading
|
||||
|
||||
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
|
||||
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||
- **Multiple meter protocol support** via rtl_tcp integration
|
||||
|
||||
## Space Weather
|
||||
|
||||
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||
- **Aurora forecast** - OVATION aurora oval visualization
|
||||
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||
- **Active solar regions** - Current sunspot region data with location and area
|
||||
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -131,6 +217,52 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **Proximity radar** visualization
|
||||
- **Device type breakdown** chart
|
||||
|
||||
## BT Locate (SAR Bluetooth Device Location)
|
||||
|
||||
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
||||
|
||||
### Core Features
|
||||
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
||||
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
||||
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
||||
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
||||
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
||||
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
||||
- **Distance estimation** - Log-distance path loss model with environment presets
|
||||
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
||||
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
||||
|
||||
### Environment Presets
|
||||
- **Open Field** (n=2.0) - Free space path loss
|
||||
- **Outdoor** (n=2.2) - Typical outdoor environment
|
||||
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
||||
|
||||
### Map & Trail
|
||||
- Interactive Leaflet map with GPS trail visualization
|
||||
- Trail points color-coded by proximity band
|
||||
- Polyline connecting detection points for path visualization
|
||||
- Supports user-configured tile providers
|
||||
|
||||
### Requirements
|
||||
- Bluetooth adapter (built-in or USB)
|
||||
- GPS receiver (optional, falls back to manual coordinates)
|
||||
|
||||
## GPS Mode
|
||||
|
||||
Real-time GPS position tracking with live map visualization.
|
||||
|
||||
### Features
|
||||
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
||||
- **Interactive map** - Current position on Leaflet map with track history
|
||||
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
||||
- **Satellite info** - Number of satellites in view and fix quality
|
||||
- **Track recording** - Record GPS tracks with export capability
|
||||
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
||||
|
||||
### Requirements
|
||||
- USB GPS receiver connected via gpsd
|
||||
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
||||
|
||||
## TSCM Counter-Surveillance Mode
|
||||
|
||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
# iNTERCEPT UI Guide
|
||||
|
||||
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Design Tokens](#design-tokens)
|
||||
2. [Base Templates](#base-templates)
|
||||
3. [Navigation](#navigation)
|
||||
4. [Components](#components)
|
||||
5. [Adding a New Module Page](#adding-a-new-module-page)
|
||||
6. [Adding a New Dashboard](#adding-a-new-dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Design Tokens
|
||||
|
||||
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
|
||||
|
||||
### Colors
|
||||
|
||||
```css
|
||||
/* Backgrounds (layered depth) */
|
||||
--bg-primary: #0a0c10; /* Darkest - page background */
|
||||
--bg-secondary: #0f1218; /* Panels, sidebars */
|
||||
--bg-tertiary: #151a23; /* Cards, elevated elements */
|
||||
--bg-card: #121620; /* Card backgrounds */
|
||||
--bg-elevated: #1a202c; /* Hover states, modals */
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-cyan: #4a9eff; /* Primary action color */
|
||||
--accent-green: #22c55e; /* Success, online status */
|
||||
--accent-red: #ef4444; /* Error, danger, stop */
|
||||
--accent-orange: #f59e0b; /* Warning */
|
||||
--accent-amber: #d4a853; /* Secondary highlight */
|
||||
|
||||
/* Text Hierarchy */
|
||||
--text-primary: #e8eaed; /* Main content */
|
||||
--text-secondary: #9ca3af; /* Secondary content */
|
||||
--text-dim: #4b5563; /* Disabled, placeholder */
|
||||
--text-muted: #374151; /* Barely visible */
|
||||
|
||||
/* Status Colors */
|
||||
--status-online: #22c55e;
|
||||
--status-warning: #f59e0b;
|
||||
--status-error: #ef4444;
|
||||
--status-offline: #6b7280;
|
||||
```
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```css
|
||||
/* Font Families */
|
||||
--font-sans: 'Inter', -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 10px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 20px;
|
||||
--text-3xl: 24px;
|
||||
--text-4xl: 30px;
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
|
||||
```css
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
```
|
||||
|
||||
### Light Theme
|
||||
|
||||
The design system supports light/dark themes via `data-theme` attribute:
|
||||
|
||||
```html
|
||||
<html data-theme="dark"> <!-- or "light" -->
|
||||
```
|
||||
|
||||
Toggle with JavaScript:
|
||||
```javascript
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base Templates
|
||||
|
||||
### `templates/layout/base.html`
|
||||
|
||||
The main base template for standard pages. Use for pages with sidebar + content layout.
|
||||
|
||||
```html
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}My Page Title{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
{% set active_mode = 'mymode' %}
|
||||
{% include 'partials/nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<div class="app-sidebar">
|
||||
<!-- Sidebar content -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-container">
|
||||
<h1>Page Title</h1>
|
||||
<!-- Page content -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Page-specific JavaScript
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### `templates/layout/base_dashboard.html`
|
||||
|
||||
Extended base for full-screen dashboards (maps, visualizations).
|
||||
|
||||
```html
|
||||
{% extends 'layout/base_dashboard.html' %}
|
||||
|
||||
{% set active_mode = 'mydashboard' %}
|
||||
|
||||
{% block dashboard_title %}MY DASHBOARD{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block stats_strip %}
|
||||
<div class="stats-strip">
|
||||
<!-- Stats bar content -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="dashboard-map-container">
|
||||
<!-- Main visualization -->
|
||||
</div>
|
||||
<div class="dashboard-sidebar">
|
||||
<!-- Sidebar panels -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
### Including Navigation
|
||||
|
||||
```html
|
||||
{% set active_mode = 'pager' %}
|
||||
{% include 'partials/nav.html' %}
|
||||
```
|
||||
|
||||
### Valid `active_mode` Values
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `pager` | Pager decoding |
|
||||
| `sensor` | 433MHz sensors |
|
||||
| `rtlamr` | Utility meters |
|
||||
| `adsb` | Aircraft tracking |
|
||||
| `ais` | Vessel tracking |
|
||||
| `aprs` | Amateur radio |
|
||||
| `wifi` | WiFi scanning |
|
||||
| `bluetooth` | Bluetooth scanning |
|
||||
| `tscm` | Counter-surveillance |
|
||||
| `satellite` | Satellite tracking |
|
||||
| `sstv` | ISS SSTV |
|
||||
| `listening` | Listening post |
|
||||
| `spystations` | Spy stations |
|
||||
| `meshtastic` | Mesh networking |
|
||||
| `weathersat` | Weather satellites |
|
||||
| `sstv_general` | HF SSTV |
|
||||
| `gps` | GPS tracking |
|
||||
| `websdr` | WebSDR |
|
||||
| `subghz` | Sub-GHz analyzer |
|
||||
| `bt_locate` | BT Locate |
|
||||
| `analytics` | Analytics dashboard |
|
||||
| `spaceweather` | Space weather |
|
||||
### Navigation Groups
|
||||
|
||||
The navigation is organized into groups:
|
||||
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic
|
||||
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Card / Panel
|
||||
|
||||
```html
|
||||
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
|
||||
<p>Panel content here</p>
|
||||
{% endcall %}
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```html
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>PANEL TITLE</span>
|
||||
<div class="panel-indicator active"></div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<p>Content here</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Empty State
|
||||
|
||||
```html
|
||||
{% include 'components/empty_state.html' with context %}
|
||||
{# Or with variables: #}
|
||||
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
|
||||
{% include 'components/empty_state.html' %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
### Loading State
|
||||
|
||||
```html
|
||||
{# Inline spinner #}
|
||||
{% include 'components/loading.html' %}
|
||||
|
||||
{# With text #}
|
||||
{% with text='Loading data...', size='lg' %}
|
||||
{% include 'components/loading.html' %}
|
||||
{% endwith %}
|
||||
|
||||
{# Full overlay #}
|
||||
{% with overlay=true, text='Please wait...' %}
|
||||
{% include 'components/loading.html' %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
### Status Badge
|
||||
|
||||
```html
|
||||
{% with status='online', text='Connected', id='connectionStatus' %}
|
||||
{% include 'components/status_badge.html' %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
Status values: `online`, `offline`, `warning`, `error`, `inactive`
|
||||
|
||||
### Buttons
|
||||
|
||||
```html
|
||||
<!-- Primary action -->
|
||||
<button class="btn btn-primary">Start Tracking</button>
|
||||
|
||||
<!-- Secondary action -->
|
||||
<button class="btn btn-secondary">Cancel</button>
|
||||
|
||||
<!-- Danger action -->
|
||||
<button class="btn btn-danger">Stop</button>
|
||||
|
||||
<!-- Ghost/subtle -->
|
||||
<button class="btn btn-ghost">Settings</button>
|
||||
|
||||
<!-- Sizes -->
|
||||
<button class="btn btn-primary btn-sm">Small</button>
|
||||
<button class="btn btn-primary btn-lg">Large</button>
|
||||
|
||||
<!-- Icon button -->
|
||||
<button class="btn btn-icon btn-secondary">
|
||||
<span class="icon">...</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### Badges
|
||||
|
||||
```html
|
||||
<span class="badge">Default</span>
|
||||
<span class="badge badge-primary">Primary</span>
|
||||
<span class="badge badge-success">Online</span>
|
||||
<span class="badge badge-warning">Warning</span>
|
||||
<span class="badge badge-danger">Error</span>
|
||||
```
|
||||
|
||||
### Form Groups
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<label for="frequency">Frequency (MHz)</label>
|
||||
<input type="text" id="frequency" value="153.350">
|
||||
<span class="form-help">Enter frequency in MHz</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gain">Gain</label>
|
||||
<select id="gain">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="30">30 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="form-check">
|
||||
<input type="checkbox" id="alerts">
|
||||
<span>Enable alerts</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
### Stats Strip
|
||||
|
||||
Used in dashboards for horizontal statistics display:
|
||||
|
||||
```html
|
||||
<div class="stats-strip">
|
||||
<div class="stats-strip-inner">
|
||||
<div class="strip-stat">
|
||||
<span class="strip-value" id="count">0</span>
|
||||
<span class="strip-label">COUNT</span>
|
||||
</div>
|
||||
<div class="strip-divider"></div>
|
||||
<div class="strip-status">
|
||||
<div class="status-dot active" id="statusDot"></div>
|
||||
<span id="statusText">TRACKING</span>
|
||||
</div>
|
||||
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Module Page
|
||||
|
||||
### 1. Create the Route
|
||||
|
||||
In `routes/mymodule.py`:
|
||||
|
||||
```python
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
|
||||
|
||||
@mymodule_bp.route('/dashboard')
|
||||
def dashboard():
|
||||
return render_template('mymodule_dashboard.html',
|
||||
offline_settings=get_offline_settings())
|
||||
```
|
||||
|
||||
### 2. Register the Blueprint
|
||||
|
||||
In `routes/__init__.py`:
|
||||
|
||||
```python
|
||||
from routes.mymodule import mymodule_bp
|
||||
app.register_blueprint(mymodule_bp)
|
||||
```
|
||||
|
||||
### 3. Create the Template
|
||||
|
||||
Option A: Simple page extending base.html
|
||||
```html
|
||||
{% extends 'layout/base.html' %}
|
||||
{% set active_mode = 'mymodule' %}
|
||||
|
||||
{% block title %}My Module{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
{% include 'partials/nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Your content -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Option B: Full-screen dashboard
|
||||
```html
|
||||
{% extends 'layout/base_dashboard.html' %}
|
||||
{% set active_mode = 'mymodule' %}
|
||||
|
||||
{% block dashboard_title %}MY MODULE{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<!-- Your dashboard content -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### 4. Add to Navigation
|
||||
|
||||
In `templates/partials/nav.html`, add your module to the appropriate group:
|
||||
|
||||
```html
|
||||
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||
onclick="switchMode('mymodule')">
|
||||
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||
<span class="nav-label">My Module</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Or if it's a dashboard link:
|
||||
```html
|
||||
<a href="/mymodule/dashboard"
|
||||
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||
style="text-decoration: none;">
|
||||
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||
<span class="nav-label">My Module</span>
|
||||
</a>
|
||||
```
|
||||
|
||||
### 5. Create Stylesheet
|
||||
|
||||
In `static/css/mymodule.css`:
|
||||
|
||||
```css
|
||||
/**
|
||||
* My Module Styles
|
||||
*/
|
||||
@import url('./core/variables.css');
|
||||
|
||||
/* Your styles using design tokens */
|
||||
.mymodule-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Dashboard
|
||||
|
||||
For full-screen dashboards like ADSB, AIS, or Satellite:
|
||||
|
||||
### 1. Create the Template
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MY DASHBOARD // iNTERCEPT</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||
|
||||
<!-- Design tokens (required) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
{% if offline_settings.fonts_source == 'local' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
<!-- External libraries if needed -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Dashboard styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Background effects -->
|
||||
<div class="radar-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<a href="/" style="color: inherit; text-decoration: none;">
|
||||
MY DASHBOARD
|
||||
<span>// iNTERCEPT</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
||||
<a href="/" class="back-link">Main Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Unified Navigation -->
|
||||
{% set active_mode = 'mydashboard' %}
|
||||
{% include 'partials/nav.html' %}
|
||||
|
||||
<!-- Stats Strip -->
|
||||
<div class="stats-strip">
|
||||
<!-- Stats content -->
|
||||
</div>
|
||||
|
||||
<!-- Main Dashboard Content -->
|
||||
<main class="dashboard">
|
||||
<!-- Your dashboard layout -->
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Dashboard JavaScript
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 2. Create the Stylesheet
|
||||
|
||||
```css
|
||||
/**
|
||||
* My Dashboard Styles
|
||||
*/
|
||||
@import url('./core/variables.css');
|
||||
|
||||
:root {
|
||||
/* Dashboard-specific aliases */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
--bg-card: var(--bg-tertiary);
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Your dashboard styles */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
|
||||
- Use design tokens for all colors, spacing, and typography
|
||||
- Include the nav partial on all pages for consistent navigation
|
||||
- Set `active_mode` before including the nav partial
|
||||
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
|
||||
- Support both light and dark themes
|
||||
- Test on mobile viewports
|
||||
|
||||
### DON'T
|
||||
|
||||
- Hardcode color values - use CSS variables
|
||||
- Create new color variations without adding to tokens
|
||||
- Duplicate navigation markup - use the partial
|
||||
- Skip the favicon and design tokens imports
|
||||
- Use inline styles for layout (use utility classes)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
templates/
|
||||
├── layout/
|
||||
│ ├── base.html # Standard page base
|
||||
│ └── base_dashboard.html # Dashboard page base
|
||||
├── partials/
|
||||
│ ├── nav.html # Unified navigation
|
||||
│ ├── page_header.html # Page title component
|
||||
│ └── settings-modal.html # Settings modal
|
||||
├── components/
|
||||
│ ├── card.html # Panel/card component
|
||||
│ ├── empty_state.html # Empty state placeholder
|
||||
│ ├── loading.html # Loading spinner
|
||||
│ ├── stats_strip.html # Stats bar component
|
||||
│ └── status_badge.html # Status indicator
|
||||
├── index.html # Main dashboard
|
||||
├── adsb_dashboard.html # Aircraft tracking
|
||||
├── ais_dashboard.html # Vessel tracking
|
||||
└── satellite_dashboard.html # Satellite tracking
|
||||
|
||||
static/css/
|
||||
├── core/
|
||||
│ ├── variables.css # Design tokens
|
||||
│ ├── base.css # Reset & typography
|
||||
│ ├── components.css # Component styles
|
||||
│ └── layout.css # Layout styles
|
||||
├── index.css # Main dashboard styles
|
||||
├── adsb_dashboard.css # Aircraft dashboard
|
||||
├── ais_dashboard.css # Vessel dashboard
|
||||
├── satellite_dashboard.css # Satellite dashboard
|
||||
└── responsive.css # Responsive breakpoints
|
||||
```
|
||||
@@ -57,92 +57,153 @@ INTERCEPT automatically detects known trackers:
|
||||
- Samsung SmartTag
|
||||
- Chipolo
|
||||
|
||||
## Sub-GHz Analyzer
|
||||
|
||||
1. **Connect HackRF** - Plug in your HackRF One device
|
||||
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
|
||||
3. **Start Capture** - Click "Start Capture" to begin signal analysis
|
||||
4. **View Spectrum** - Real-time spectrum visualization of the selected band
|
||||
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
|
||||
|
||||
### Supported Protocols
|
||||
|
||||
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
||||
|
||||
## VDL2 (Aircraft Datalink)
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
|
||||
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
|
||||
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
|
||||
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
|
||||
7. **Export** - Use CSV or JSON export buttons to save captured messages
|
||||
|
||||
### Tips
|
||||
|
||||
- VDL2 is most active near airports and along flight corridors
|
||||
- Multiple frequencies can be monitored simultaneously for better coverage
|
||||
- VDL2 data is also accessible from the ADS-B dashboard
|
||||
|
||||
## Listening Post
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Set Frequency Range** - Define start and end frequencies for scanning
|
||||
3. **Start Scanning** - Click "Start Scan" for wideband sweep
|
||||
4. **View Signals** - Discovered signals are listed with frequency and SNR
|
||||
5. **Tune In** - Click a signal to tune the audio demodulator
|
||||
6. **Listen** - Real-time audio plays in your browser
|
||||
|
||||
### Demodulation Modes
|
||||
|
||||
- **FM** - Narrowband and wideband FM
|
||||
- **SSB** - Upper and lower sideband for amateur radio and shortwave
|
||||
|
||||
## Aircraft Mode (ADS-B)
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||
2. **Check Tools** - Ensure dump1090 or readsb is installed
|
||||
3. **Set Location** - Choose location source:
|
||||
- **Manual Entry** - Type coordinates directly
|
||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||
- **Shared Location** - By default, the observer location is shared across modules
|
||||
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||
3. **Set Location** - Choose location source:
|
||||
- **Manual Entry** - Type coordinates directly
|
||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||
- **Shared Location** - By default, the observer location is shared across modules
|
||||
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||
6. **Click Aircraft** - Click markers for detailed information
|
||||
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
|
||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||
|
||||
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
|
||||
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||
|
||||
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
|
||||
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
||||
|
||||
### Emergency Squawks
|
||||
|
||||
The system highlights aircraft transmitting emergency squawks:
|
||||
- **7500** - Hijack
|
||||
- **7600** - Radio failure
|
||||
- **7700** - General emergency
|
||||
|
||||
## ADS-B History (Optional)
|
||||
|
||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||
|
||||
### Enable History
|
||||
|
||||
Set the following environment variables (Docker recommended):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
### Docker Setup
|
||||
|
||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||
|
||||
```bash
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
```
|
||||
|
||||
### Using the History Dashboard
|
||||
|
||||
1. Open **/adsb/history**
|
||||
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||
3. View aircraft history and timelines
|
||||
4. Stop tracking when desired (session history is recorded)
|
||||
The system highlights aircraft transmitting emergency squawks:
|
||||
- **7500** - Hijack
|
||||
- **7600** - Radio failure
|
||||
- **7700** - General emergency
|
||||
|
||||
## ACARS Messaging
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
|
||||
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
|
||||
5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
|
||||
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
|
||||
7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
|
||||
|
||||
### Tips
|
||||
|
||||
- A vertical polarization antenna works best for ACARS
|
||||
- Quarter-wave dipole: 57 cm per element at 130 MHz
|
||||
- Stock SDR antenna may work at close range near airports
|
||||
- Outdoor placement with clear sky view significantly improves reception
|
||||
|
||||
## ADS-B History (Optional)
|
||||
|
||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||
|
||||
### Enable History
|
||||
|
||||
Set the following environment variables (Docker recommended):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
### Docker Setup
|
||||
|
||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||
|
||||
```bash
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
```
|
||||
|
||||
### Using the History Dashboard
|
||||
|
||||
1. Open **/adsb/history**
|
||||
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||
3. View aircraft history and timelines
|
||||
4. Stop tracking when desired (session history is recorded)
|
||||
|
||||
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
|
||||
|
||||
## Satellite Mode
|
||||
|
||||
@@ -163,6 +224,236 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||
4. Select satellites to add
|
||||
|
||||
## Weather Satellites
|
||||
|
||||
1. **Set Location** - Enter observer coordinates or use GPS
|
||||
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
|
||||
3. **View Passes** - Upcoming passes shown with polar plot and ground track
|
||||
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
|
||||
5. **View Images** - Decoded imagery appears in the gallery
|
||||
|
||||
### Auto-Scheduler
|
||||
|
||||
Enable the auto-scheduler to automatically capture passes:
|
||||
- Calculates upcoming NOAA and Meteor passes for your location
|
||||
- Starts SatDump at the correct time and frequency
|
||||
- Decoded images are saved with timestamps
|
||||
|
||||
## Space Weather
|
||||
|
||||
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
|
||||
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
|
||||
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
|
||||
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
|
||||
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
|
||||
6. **Alerts** - Review current SWPC space weather alerts and warnings
|
||||
7. **Active Regions** - View solar active region data (number, location, area)
|
||||
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
|
||||
|
||||
### Tips
|
||||
|
||||
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
|
||||
- Check HF band conditions before operating on shortwave frequencies
|
||||
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
|
||||
- D-RAP maps show where HF absorption is highest — useful for path planning
|
||||
- Solar imagery updates approximately every 15 minutes from NASA SDO
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
|
||||
3. **View Map** - Vessels appear on the interactive maritime map
|
||||
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
|
||||
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
|
||||
|
||||
### VHF DSC Channel 70
|
||||
|
||||
Digital Selective Calling monitoring runs alongside AIS:
|
||||
- Distress, Urgency, Safety, and Routine messages
|
||||
- Distress positions plotted with pulsing alert markers
|
||||
- Audio alerts for critical messages
|
||||
|
||||
## WebSDR
|
||||
|
||||
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
|
||||
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
|
||||
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
|
||||
4. **Select Receiver** - Click a receiver from the list to connect
|
||||
5. **Listen** - Audio streams in real-time via WebSocket
|
||||
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
|
||||
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
|
||||
|
||||
### Tips
|
||||
|
||||
- Requires an internet connection to access the KiwiSDR network
|
||||
- Receiver list is cached for 1 hour to reduce API load
|
||||
- Receivers are sorted by distance from your location
|
||||
- Integrated spy station presets allow quick tuning to SIGINT targets
|
||||
|
||||
## ISS SSTV
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
|
||||
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
|
||||
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
|
||||
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
|
||||
7. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
|
||||
|
||||
### Tips
|
||||
|
||||
- A V-dipole or better antenna is required (stock antenna will not work)
|
||||
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
|
||||
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
|
||||
- Best passes have elevation > 30 degrees above horizon
|
||||
- Doppler shift tracking dramatically improves reception quality
|
||||
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
|
||||
- Outdoor antenna placement with clear sky view is essential
|
||||
|
||||
## HF SSTV
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
|
||||
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
|
||||
5. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||
6. **View Images** - Decoded amateur radio images appear in the gallery
|
||||
|
||||
### Tips
|
||||
|
||||
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
|
||||
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
|
||||
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
|
||||
- Weekend activity peaks on most HF bands
|
||||
- Amateur license is not required to receive (listen-only)
|
||||
|
||||
## APRS
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
|
||||
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
|
||||
4. **View Map** - Station positions appear on the interactive map
|
||||
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
|
||||
|
||||
## Utility Meters
|
||||
|
||||
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
|
||||
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
|
||||
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
|
||||
|
||||
## BT Locate (SAR Device Location)
|
||||
|
||||
1. **Set Target** - Enter one or more target identifiers:
|
||||
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
|
||||
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
|
||||
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
|
||||
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
|
||||
2. **Choose Environment** - Select the RF environment preset:
|
||||
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
|
||||
- **Indoor** (n=3.0) - For buildings with walls and obstacles
|
||||
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||
4. **Monitor HUD** - The proximity display shows:
|
||||
- Proximity band (IMMEDIATE / NEAR / FAR)
|
||||
- Estimated distance in meters
|
||||
- Raw RSSI and smoothed RSSI average
|
||||
- Detection count and GPS-tagged points
|
||||
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
|
||||
7. **Review Trail** - Check the map for GPS-tagged detection trail
|
||||
|
||||
### Hand-off from Bluetooth Mode
|
||||
|
||||
1. Open Bluetooth scanning mode and find the target device
|
||||
2. Click the "Locate" button on the device card
|
||||
3. BT Locate opens with the device pre-filled
|
||||
4. Click "Start Locate" to begin tracking
|
||||
|
||||
### Tips
|
||||
|
||||
- For devices with address randomization (iPhones, modern Android), use the IRK method
|
||||
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
|
||||
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
|
||||
- Clear the trail when starting a new search area
|
||||
|
||||
## GPS Mode
|
||||
|
||||
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
||||
2. **View Map** - Your position appears on the interactive map with a track trail
|
||||
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
|
||||
4. **Record Track** - Enable track recording to save your path
|
||||
|
||||
### Tips
|
||||
|
||||
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
|
||||
- GPS fix may take 30-60 seconds after cold start
|
||||
- Accuracy improves with more satellites in view
|
||||
|
||||
## TSCM (Counter-Surveillance)
|
||||
|
||||
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
|
||||
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
|
||||
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
|
||||
4. **Start Sweep** - Click "Start Sweep" to begin scanning
|
||||
5. **Review Results** - Detected devices are classified and scored by threat level
|
||||
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
|
||||
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
|
||||
|
||||
### Threat Levels
|
||||
|
||||
- **Informational (0-2)** - Known or expected devices
|
||||
- **Needs Review (3-5)** - Unusual devices requiring assessment
|
||||
- **High Interest (6+)** - Multiple indicators warrant investigation
|
||||
|
||||
### Tips
|
||||
|
||||
- Record a baseline in a known clean environment before conducting sweeps
|
||||
- Use the meeting window feature to flag new RF signatures during sensitive periods
|
||||
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||
|
||||
## Spy Stations
|
||||
|
||||
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
|
||||
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
|
||||
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
|
||||
5. **View Details** - Click "Details" on a station card for full information
|
||||
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
|
||||
|
||||
### Tips
|
||||
|
||||
- Data sourced from priyom.org (non-profit monitoring community)
|
||||
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
|
||||
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
|
||||
- Legal to monitor in most countries (check local regulations)
|
||||
- No decryption or content decoding is included — this is a reference database
|
||||
|
||||
## Meshtastic
|
||||
|
||||
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||
2. **Start** - Click "Start" to connect to the mesh network
|
||||
3. **View Messages** - Real-time message stream from the mesh
|
||||
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
||||
5. **Send Messages** - Type messages to broadcast on the mesh
|
||||
|
||||
## Offline Mode
|
||||
|
||||
1. **Open Settings** - Click the gear icon in the navigation bar
|
||||
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
|
||||
3. **Configure Sources** - Switch assets and fonts from CDN to local
|
||||
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
|
||||
5. **Check Assets** - Click "Check Assets" to verify all local files are present
|
||||
|
||||
### Tips
|
||||
|
||||
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
|
||||
- Assets are stored in the `static/vendor/` directory
|
||||
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
|
||||
- Missing assets fail gracefully with console warnings
|
||||
- Useful for air-gapped environments, field deployments, or reducing latency
|
||||
|
||||
## Remote Agents (Distributed SIGINT)
|
||||
|
||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||
|
||||
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -11,6 +11,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="bg-canvas"></canvas>
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<a href="#" class="nav-logo">iNTERCEPT</a>
|
||||
@@ -35,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">15+</span>
|
||||
<span class="stat-value">25+</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -58,97 +59,149 @@
|
||||
<h2>Capabilities</h2>
|
||||
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📟</div>
|
||||
<h3>Pager Decoding</h3>
|
||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">✈️</div>
|
||||
<h3>Aircraft Tracking</h3>
|
||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<h3>433MHz Sensors</h3>
|
||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📻</div>
|
||||
<h3>Listening Post</h3>
|
||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<h3>Satellite Tracking</h3>
|
||||
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📶</div>
|
||||
<h3>WiFi Scanning</h3>
|
||||
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔵</div>
|
||||
<h3>Bluetooth Scanning</h3>
|
||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Meter Reading</h3>
|
||||
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚢</div>
|
||||
<h3>Vessel Tracking</h3>
|
||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔢</div>
|
||||
<h3>Spy Stations</h3>
|
||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>Remote Agents</h3>
|
||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📴</div>
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<h3>Meshtastic</h3>
|
||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🖼️</div>
|
||||
<h3>ISS SSTV</h3>
|
||||
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||
</div>
|
||||
<div class="carousel-filters">
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="signals">Signals</button>
|
||||
<button class="filter-btn" data-filter="tracking">Tracking</button>
|
||||
<button class="filter-btn" data-filter="space">Space</button>
|
||||
<button class="filter-btn" data-filter="wireless">Wireless</button>
|
||||
<button class="filter-btn" data-filter="intel">Intel</button>
|
||||
<button class="filter-btn" data-filter="platform">Platform</button>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrapper">
|
||||
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
||||
<div class="carousel-track">
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
||||
<h3>Pager Decoding</h3>
|
||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
|
||||
<h3>433MHz Sensors</h3>
|
||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
|
||||
<h3>Sub-GHz Analyzer</h3>
|
||||
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
|
||||
<h3>Listening Post</h3>
|
||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||
<h3>WebSDR</h3>
|
||||
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
|
||||
<h3>Spy Stations</h3>
|
||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<h3>APRS</h3>
|
||||
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||
<h3>Utility Meters</h3>
|
||||
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||
<h3>Aircraft Tracking</h3>
|
||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
||||
<h3>ACARS</h3>
|
||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
||||
<h3>VDL2</h3>
|
||||
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
|
||||
<h3>Vessel Tracking</h3>
|
||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></div>
|
||||
<h3>Satellite Tracking</h3>
|
||||
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</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"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
|
||||
<h3>Weather Satellites</h3>
|
||||
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div>
|
||||
<h3>ISS SSTV</h3>
|
||||
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></div>
|
||||
<h3>HF SSTV</h3>
|
||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||
<h3>Space Weather</h3>
|
||||
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||
<h3>WiFi Scanning</h3>
|
||||
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
|
||||
<h3>Bluetooth Scanning</h3>
|
||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
|
||||
<h3>BT Locate</h3>
|
||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
||||
<h3>Meshtastic</h3>
|
||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="platform">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></div>
|
||||
<h3>Remote Agents</h3>
|
||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="platform">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></div>
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||
</div>
|
||||
|
||||
<div class="carousel-indicators" id="carousel-indicators"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -194,6 +247,50 @@
|
||||
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/spy-stations.png" alt="Spy Stations Database">
|
||||
<span class="screenshot-label">Spy Stations</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/gps.png" alt="GPS Receiver">
|
||||
<span class="screenshot-label">GPS Receiver</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
||||
<span class="screenshot-label">WebSDR</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/aprs.png" alt="APRS Tracker">
|
||||
<span class="screenshot-label">APRS Tracker</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
||||
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
||||
<span class="screenshot-label">Weather Satellite</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
|
||||
<span class="screenshot-label">Space Weather</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
|
||||
<span class="screenshot-label">Space Weather — Solar & Aurora</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
||||
<span class="screenshot-label">Satellite Tracker</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
|
||||
<span class="screenshot-label">ISS SSTV</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -224,7 +321,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
|
||||
<div class="code-block">
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker compose up -d</code></pre>
|
||||
docker compose --profile basic up -d --build</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||
</div>
|
||||
@@ -278,6 +375,36 @@ docker compose up -d</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="support">
|
||||
<div class="container">
|
||||
<h2>Support & Contact</h2>
|
||||
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
|
||||
|
||||
<div class="support-grid">
|
||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
|
||||
<h3>Buy Me a Coffee</h3>
|
||||
<p>Support development with a one-time donation</p>
|
||||
</a>
|
||||
<a href="#" id="email-card" class="support-card" onclick="return false;">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
|
||||
<h3>Email</h3>
|
||||
<p id="email-text">Click to reveal</p>
|
||||
</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||||
<h3>Discord</h3>
|
||||
<p>Join the community for help and discussion</p>
|
||||
</a>
|
||||
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||
<h3>Report an Issue</h3>
|
||||
<p>Bug reports and feature requests on GitHub</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
@@ -288,12 +415,14 @@ docker compose up -d</code></pre>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||
<a href="#" id="footer-email">Email</a>
|
||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</a>
|
||||
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,6 +465,334 @@ docker compose up -d</code></pre>
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
});
|
||||
|
||||
// Carousel functionality
|
||||
(function() {
|
||||
const track = document.querySelector('.carousel-track');
|
||||
const cards = Array.from(track.querySelectorAll('.feature-card'));
|
||||
const leftArrow = document.querySelector('.carousel-arrow-left');
|
||||
const rightArrow = document.querySelector('.carousel-arrow-right');
|
||||
const filterBtns = document.querySelectorAll('.filter-btn');
|
||||
const indicatorContainer = document.getElementById('carousel-indicators');
|
||||
|
||||
const SCROLL_AMOUNT = 300;
|
||||
|
||||
function updateArrows() {
|
||||
leftArrow.disabled = track.scrollLeft <= 0;
|
||||
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
|
||||
}
|
||||
|
||||
function buildIndicators() {
|
||||
const visible = cards.filter(c => !c.classList.contains('hidden'));
|
||||
const totalWidth = visible.length * 300;
|
||||
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
|
||||
indicatorContainer.innerHTML = '';
|
||||
for (let i = 0; i < pages; i++) {
|
||||
const dot = document.createElement('button');
|
||||
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||||
dot.addEventListener('click', () => {
|
||||
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
|
||||
});
|
||||
indicatorContainer.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
function updateIndicators() {
|
||||
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
|
||||
if (!dots.length) return;
|
||||
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
|
||||
const idx = Math.round(ratio * (dots.length - 1));
|
||||
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
|
||||
}
|
||||
|
||||
leftArrow.addEventListener('click', () => {
|
||||
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
rightArrow.addEventListener('click', () => {
|
||||
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
track.addEventListener('scroll', () => {
|
||||
updateArrows();
|
||||
updateIndicators();
|
||||
});
|
||||
|
||||
filterBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const filter = btn.dataset.filter;
|
||||
|
||||
cards.forEach(card => {
|
||||
if (filter === 'all' || card.dataset.category === filter) {
|
||||
card.classList.remove('hidden');
|
||||
} else {
|
||||
card.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
track.scrollTo({ left: 0 });
|
||||
buildIndicators();
|
||||
updateArrows();
|
||||
});
|
||||
});
|
||||
|
||||
buildIndicators();
|
||||
updateArrows();
|
||||
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
|
||||
})();
|
||||
|
||||
// Obfuscated email - assembled at runtime to defeat scrapers
|
||||
(function() {
|
||||
const p = ['smittix', 'outlook', 'com'];
|
||||
const addr = p[0] + '@' + p[1] + '.' + p[2];
|
||||
const card = document.getElementById('email-card');
|
||||
const text = document.getElementById('email-text');
|
||||
const footerLink = document.getElementById('footer-email');
|
||||
let revealed = false;
|
||||
|
||||
card.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (!revealed) {
|
||||
text.textContent = addr;
|
||||
revealed = true;
|
||||
} else {
|
||||
window.location.href = 'mail' + 'to:' + addr;
|
||||
}
|
||||
});
|
||||
|
||||
footerLink.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
window.location.href = 'mail' + 'to:' + addr;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Animated satellite & signal background
|
||||
(function() {
|
||||
const canvas = document.getElementById('bg-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let w, h, dpr;
|
||||
let orbits = [];
|
||||
let pulses = [];
|
||||
let particles = [];
|
||||
let mouse = { x: -1000, y: -1000 };
|
||||
|
||||
function resize() {
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = window.innerWidth;
|
||||
h = document.documentElement.scrollHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
// Orbital paths with satellites
|
||||
function createOrbits() {
|
||||
orbits = [];
|
||||
const count = Math.max(4, Math.floor(w / 300));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cx = Math.random() * w;
|
||||
const cy = Math.random() * h;
|
||||
const rx = 120 + Math.random() * 280;
|
||||
const ry = 40 + Math.random() * 100;
|
||||
const tilt = (Math.random() - 0.5) * 1.2;
|
||||
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
|
||||
const sats = [];
|
||||
const satCount = 1 + Math.floor(Math.random() * 2);
|
||||
for (let j = 0; j < satCount; j++) {
|
||||
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
|
||||
}
|
||||
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
|
||||
}
|
||||
}
|
||||
|
||||
// Floating signal particles (tiny dots drifting upward)
|
||||
function createParticles() {
|
||||
particles = [];
|
||||
const count = Math.max(30, Math.floor((w * h) / 25000));
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vy: -(0.08 + Math.random() * 0.15),
|
||||
vx: (Math.random() - 0.5) * 0.1,
|
||||
size: 0.5 + Math.random() * 1.2,
|
||||
alpha: 0.1 + Math.random() * 0.25,
|
||||
flicker: Math.random() * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function spawnPulse(x, y) {
|
||||
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
|
||||
}
|
||||
|
||||
function drawOrbitPath(orbit) {
|
||||
ctx.save();
|
||||
ctx.translate(orbit.cx, orbit.cy);
|
||||
ctx.rotate(orbit.tilt);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSatellite(orbit, sat, dt) {
|
||||
sat.angle += orbit.speed * dt;
|
||||
const cos = Math.cos(orbit.tilt);
|
||||
const sin = Math.sin(orbit.tilt);
|
||||
const ex = orbit.rx * Math.cos(sat.angle);
|
||||
const ey = orbit.ry * Math.sin(sat.angle);
|
||||
const sx = orbit.cx + ex * cos - ey * sin;
|
||||
const sy = orbit.cy + ex * sin + ey * cos;
|
||||
|
||||
// Satellite dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
|
||||
ctx.fill();
|
||||
|
||||
// Faint glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
|
||||
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
|
||||
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
|
||||
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fill();
|
||||
|
||||
// Periodic signal pulse
|
||||
sat.pulseTimer += dt;
|
||||
if (sat.pulseTimer > 3000 + Math.random() * 500) {
|
||||
sat.pulseTimer = 0;
|
||||
spawnPulse(sx, sy);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPulses(dt) {
|
||||
for (let i = pulses.length - 1; i >= 0; i--) {
|
||||
const p = pulses[i];
|
||||
p.r += dt * 0.025;
|
||||
p.alpha = 0.35 * (1 - p.r / p.maxR);
|
||||
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Second ring
|
||||
if (p.r > 12) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticles(dt, time) {
|
||||
for (const p of particles) {
|
||||
p.y += p.vy * dt * 0.06;
|
||||
p.x += p.vx * dt * 0.06;
|
||||
p.flicker += dt * 0.002;
|
||||
|
||||
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
|
||||
if (p.x < -10) p.x = w + 10;
|
||||
if (p.x > w + 10) p.x = -10;
|
||||
|
||||
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
|
||||
|
||||
// Mouse interaction - subtle brighten
|
||||
const dx = p.x - mouse.x;
|
||||
const dy = p.y - mouse.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Faint grid lines (signal grid)
|
||||
function drawGrid(time) {
|
||||
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
|
||||
ctx.lineWidth = 1;
|
||||
const spacing = 120;
|
||||
const offset = (time * 0.005) % spacing;
|
||||
|
||||
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
function animate(now) {
|
||||
const dt = last ? Math.min(now - last, 50) : 16;
|
||||
last = now;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
drawGrid(now);
|
||||
|
||||
for (const orbit of orbits) {
|
||||
drawOrbitPath(orbit);
|
||||
for (const sat of orbit.sats) {
|
||||
drawSatellite(orbit, sat, dt);
|
||||
}
|
||||
}
|
||||
|
||||
drawPulses(dt);
|
||||
drawParticles(dt, now);
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Track mouse for particle interaction
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
mouse.x = e.clientX;
|
||||
mouse.y = e.clientY + window.scrollY;
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
let resizeTimer;
|
||||
function handleResize() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
resize();
|
||||
createOrbits();
|
||||
createParticles();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Keep canvas height synced with document
|
||||
const ro = new ResizeObserver(() => { handleResize(); });
|
||||
ro.observe(document.documentElement);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
resize();
|
||||
createOrbits();
|
||||
createParticles();
|
||||
requestAnimationFrame(animate);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
--gradient-end: #0088ff;
|
||||
}
|
||||
|
||||
/* Animated background canvas */
|
||||
#bg-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body > *:not(#bg-canvas) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -245,18 +261,74 @@ section h2 {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
/* Category filter tabs */
|
||||
.carousel-filters {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Carousel */
|
||||
.carousel-wrapper {
|
||||
position: relative;
|
||||
padding: 0 56px;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.carousel-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
flex: 0 0 280px;
|
||||
scroll-snap-align: start;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
transition: all 0.3s;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.feature-card.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
@@ -266,8 +338,15 @@ section h2 {
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
@@ -283,6 +362,81 @@ section h2 {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Carousel arrows */
|
||||
.carousel-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.carousel-arrow:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.carousel-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.carousel-arrow:disabled:hover {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.carousel-arrow-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.carousel-arrow-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Carousel indicators */
|
||||
.carousel-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.carousel-dot.active {
|
||||
background: var(--accent);
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.carousel-dot:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Screenshots */
|
||||
.screenshot-gallery {
|
||||
display: grid;
|
||||
@@ -550,6 +704,72 @@ section h2 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Support & Contact */
|
||||
.support {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.support-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.support-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.support-card.support-coffee {
|
||||
border-color: rgba(255, 193, 59, 0.3);
|
||||
}
|
||||
|
||||
.support-card.support-coffee:hover {
|
||||
border-color: #ffc13b;
|
||||
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
|
||||
}
|
||||
|
||||
.support-card.support-coffee .support-icon {
|
||||
color: #ffc13b;
|
||||
}
|
||||
|
||||
.support-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0 auto 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.support-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.support-card h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.support-card p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: var(--bg-secondary);
|
||||
@@ -641,14 +861,22 @@ section h2 {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
.carousel-wrapper {
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
flex: 0 0 260px;
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.install-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -669,14 +897,35 @@ section h2 {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.carousel-wrapper {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.carousel-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
flex: 0 0 260px;
|
||||
}
|
||||
|
||||
.carousel-filters {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download sample NOAA APT recordings for testing the weather satellite
|
||||
# test-decode feature. These are FM-demodulated audio WAV files.
|
||||
#
|
||||
# Usage:
|
||||
# ./download-weather-sat-samples.sh
|
||||
# docker exec intercept /app/download-weather-sat-samples.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
|
||||
mkdir -p "$SAMPLE_DIR"
|
||||
|
||||
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
|
||||
|
||||
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
|
||||
# Source: https://github.com/martinber/noaa-apt
|
||||
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
|
||||
echo " -> noaa_apt_argentina.wav (18 MB) ..."
|
||||
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
|
||||
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
|
||||
else
|
||||
echo " -> noaa_apt_argentina.wav (already exists)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Test decode with:"
|
||||
echo " Satellite: NOAA-18"
|
||||
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
|
||||
echo " Sample rate: 11025 Hz"
|
||||
@@ -0,0 +1,210 @@
|
||||
DMSP 5D-3 F16 (USA 172)
|
||||
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
|
||||
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
|
||||
METEOSAT-9 (MSG-2)
|
||||
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
|
||||
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
|
||||
DMSP 5D-3 F17 (USA 191)
|
||||
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
|
||||
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
|
||||
FENGYUN 3A
|
||||
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
|
||||
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
|
||||
GOES 14
|
||||
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
|
||||
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
|
||||
DMSP 5D-3 F18 (USA 210)
|
||||
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
|
||||
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
|
||||
EWS-G2 (GOES 15)
|
||||
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
|
||||
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
|
||||
COMS 1
|
||||
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
|
||||
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
|
||||
FENGYUN 3B
|
||||
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
|
||||
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
|
||||
SUOMI NPP
|
||||
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
|
||||
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
|
||||
METEOSAT-10 (MSG-3)
|
||||
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
|
||||
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
|
||||
METOP-B
|
||||
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
|
||||
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
|
||||
INSAT-3D
|
||||
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
|
||||
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
|
||||
FENGYUN 3C
|
||||
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
|
||||
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
|
||||
METEOR-M 2
|
||||
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
|
||||
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
|
||||
HIMAWARI-8
|
||||
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
|
||||
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
|
||||
FENGYUN 2G
|
||||
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
|
||||
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
|
||||
METEOSAT-11 (MSG-4)
|
||||
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
|
||||
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
|
||||
ELEKTRO-L 2
|
||||
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
|
||||
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
|
||||
INSAT-3DR
|
||||
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
|
||||
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
|
||||
HIMAWARI-9
|
||||
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
|
||||
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
|
||||
GOES 16
|
||||
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
|
||||
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
|
||||
FENGYUN 4A
|
||||
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
|
||||
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
|
||||
CYGFM05
|
||||
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
|
||||
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
|
||||
CYGFM04
|
||||
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
|
||||
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
|
||||
CYGFM02
|
||||
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
|
||||
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
|
||||
CYGFM01
|
||||
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
|
||||
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
|
||||
CYGFM08
|
||||
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
|
||||
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
|
||||
CYGFM07
|
||||
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
|
||||
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
|
||||
CYGFM03
|
||||
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
|
||||
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
|
||||
FENGYUN 3D
|
||||
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
|
||||
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
|
||||
NOAA 20 (JPSS-1)
|
||||
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
|
||||
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
|
||||
GOES 17
|
||||
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
|
||||
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
|
||||
FENGYUN 2H
|
||||
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
|
||||
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
|
||||
METOP-C
|
||||
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
|
||||
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
|
||||
GEO-KOMPSAT-2A
|
||||
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
|
||||
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
|
||||
METEOR-M2 2
|
||||
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
|
||||
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
|
||||
ARKTIKA-M 1
|
||||
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
|
||||
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
|
||||
FENGYUN 3E
|
||||
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
|
||||
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
|
||||
GOES 18
|
||||
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
|
||||
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
|
||||
NOAA 21 (JPSS-2)
|
||||
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
|
||||
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
|
||||
METEOSAT-12 (MTG-I1)
|
||||
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
|
||||
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
|
||||
TIANMU-1 03
|
||||
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
|
||||
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
|
||||
TIANMU-1 04
|
||||
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
|
||||
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
|
||||
TIANMU-1 05
|
||||
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
|
||||
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
|
||||
TIANMU-1 06
|
||||
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
|
||||
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
|
||||
FENGYUN 3G
|
||||
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
|
||||
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
|
||||
METEOR-M2 3
|
||||
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
|
||||
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
|
||||
TIANMU-1 07
|
||||
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
|
||||
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
|
||||
TIANMU-1 08
|
||||
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
|
||||
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
|
||||
TIANMU-1 09
|
||||
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
|
||||
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
|
||||
TIANMU-1 10
|
||||
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
|
||||
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
|
||||
FENGYUN 3F
|
||||
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
|
||||
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
|
||||
ARKTIKA-M 2
|
||||
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
|
||||
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
|
||||
TIANMU-1 11
|
||||
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
|
||||
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
|
||||
TIANMU-1 12
|
||||
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
|
||||
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
|
||||
TIANMU-1 13
|
||||
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
|
||||
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
|
||||
TIANMU-1 14
|
||||
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
|
||||
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
|
||||
TIANMU-1 19
|
||||
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
|
||||
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
|
||||
TIANMU-1 20
|
||||
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
|
||||
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
|
||||
TIANMU-1 21
|
||||
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
|
||||
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
|
||||
TIANMU-1 22
|
||||
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
|
||||
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
|
||||
TIANMU-1 15
|
||||
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
|
||||
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
|
||||
TIANMU-1 16
|
||||
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
|
||||
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
|
||||
TIANMU-1 17
|
||||
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
|
||||
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
|
||||
TIANMU-1 18
|
||||
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
|
||||
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
|
||||
INSAT-3DS
|
||||
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
|
||||
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
|
||||
METEOR-M2 4
|
||||
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
|
||||
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
|
||||
GOES 19
|
||||
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
|
||||
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
|
||||
FENGYUN 3H
|
||||
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
|
||||
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
|
||||
@@ -843,6 +843,7 @@ class ModeManager:
|
||||
'anomalies': getattr(self, 'tscm_anomalies', []),
|
||||
'baseline': getattr(self, 'tscm_baseline', {}),
|
||||
'wifi_devices': list(self.wifi_networks.values()),
|
||||
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
||||
'bt_devices': list(self.bluetooth_devices.values()),
|
||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
||||
}
|
||||
@@ -1116,6 +1117,7 @@ class ModeManager:
|
||||
self.tscm_anomalies = []
|
||||
self.tscm_baseline = {}
|
||||
self.tscm_rf_signals = []
|
||||
self.tscm_wifi_clients = {}
|
||||
# Clear reported threat tracking sets
|
||||
if hasattr(self, '_tscm_reported_wifi'):
|
||||
self._tscm_reported_wifi.clear()
|
||||
@@ -1541,6 +1543,7 @@ class ModeManager:
|
||||
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
||||
interface = params.get('interface')
|
||||
channel = params.get('channel')
|
||||
channels = params.get('channels')
|
||||
band = params.get('band', 'abg')
|
||||
scan_type = params.get('scan_type', 'deep')
|
||||
|
||||
@@ -1571,8 +1574,21 @@ class ModeManager:
|
||||
else:
|
||||
scan_band = 'all'
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [int(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'message': 'Invalid channels'}
|
||||
|
||||
# Start deep scan
|
||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
|
||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
||||
# Start thread to sync data to agent's dictionaries
|
||||
thread = threading.Thread(
|
||||
target=self._wifi_data_sync,
|
||||
@@ -1593,7 +1609,7 @@ class ModeManager:
|
||||
|
||||
except ImportError:
|
||||
# Fallback to direct airodump-ng
|
||||
return self._start_wifi_fallback(interface, channel, band)
|
||||
return self._start_wifi_fallback(interface, channel, band, channels)
|
||||
except Exception as e:
|
||||
logger.error(f"WiFi scanner error: {e}")
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
@@ -1630,7 +1646,13 @@ class ModeManager:
|
||||
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
||||
self._wifi_scanner_instance.stop_deep_scan()
|
||||
|
||||
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
|
||||
def _start_wifi_fallback(
|
||||
self,
|
||||
interface: str | None,
|
||||
channel: int | None,
|
||||
band: str,
|
||||
channels: list[int] | str | None = None,
|
||||
) -> dict:
|
||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||
if not interface:
|
||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
||||
@@ -1658,7 +1680,22 @@ class ModeManager:
|
||||
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
||||
if gps_manager.is_running:
|
||||
cmd.append('--gpsd')
|
||||
if channel:
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [int(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'message': 'Invalid channels'}
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
cmd.append(interface)
|
||||
|
||||
@@ -1985,7 +2022,7 @@ class ModeManager:
|
||||
'agent_gps': gps_manager.position
|
||||
}
|
||||
|
||||
scanner.set_on_device_updated(on_device_updated)
|
||||
scanner.add_device_callback(on_device_updated)
|
||||
|
||||
# Start scanning
|
||||
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
||||
@@ -3113,7 +3150,10 @@ class ModeManager:
|
||||
self.tscm_anomalies = []
|
||||
if not hasattr(self, 'tscm_rf_signals'):
|
||||
self.tscm_rf_signals = []
|
||||
if not hasattr(self, 'tscm_wifi_clients'):
|
||||
self.tscm_wifi_clients = {}
|
||||
self.tscm_anomalies.clear()
|
||||
self.tscm_wifi_clients.clear()
|
||||
|
||||
# Get params for what to scan
|
||||
scan_wifi = params.get('wifi', True)
|
||||
@@ -3122,6 +3162,7 @@ class ModeManager:
|
||||
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
||||
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
||||
sdr_device = params.get('sdr_device', params.get('device', 0))
|
||||
sweep_type = params.get('sweep_type')
|
||||
|
||||
# Get baseline_id for comparison (same as local mode)
|
||||
baseline_id = params.get('baseline_id')
|
||||
@@ -3131,7 +3172,7 @@ class ModeManager:
|
||||
# Start the combined TSCM scanner thread using existing Intercept functions
|
||||
thread = threading.Thread(
|
||||
target=self._tscm_scanner_thread,
|
||||
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id),
|
||||
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
@@ -3154,7 +3195,7 @@ class ModeManager:
|
||||
|
||||
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
||||
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
||||
baseline_id: int | None = None):
|
||||
baseline_id: int | None = None, sweep_type: str | None = None):
|
||||
"""Combined TSCM scanner using existing Intercept functions.
|
||||
|
||||
NOTE: This matches local mode behavior exactly:
|
||||
@@ -3167,9 +3208,18 @@ class ModeManager:
|
||||
stop_event = self.stop_events.get(mode)
|
||||
|
||||
# Import existing Intercept TSCM functions
|
||||
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
|
||||
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
||||
logger.info("TSCM imports successful")
|
||||
|
||||
sweep_ranges = None
|
||||
if sweep_type:
|
||||
try:
|
||||
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
|
||||
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
|
||||
sweep_ranges = preset.get('ranges') if preset else None
|
||||
except Exception:
|
||||
sweep_ranges = None
|
||||
|
||||
# Load baseline if specified (same as local mode)
|
||||
baseline = None
|
||||
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
||||
@@ -3193,6 +3243,7 @@ class ModeManager:
|
||||
|
||||
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
||||
seen_wifi = {}
|
||||
seen_wifi_clients = {}
|
||||
seen_bt = {}
|
||||
|
||||
last_rf_scan = 0
|
||||
@@ -3243,6 +3294,9 @@ class ModeManager:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
@@ -3250,6 +3304,47 @@ class ModeManager:
|
||||
enriched['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.wifi_networks[bssid] = enriched
|
||||
|
||||
# WiFi clients (monitor mode only)
|
||||
try:
|
||||
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
||||
for client in wifi_clients:
|
||||
mac = (client.get('mac') or '').upper()
|
||||
if not mac or mac in seen_wifi_clients:
|
||||
continue
|
||||
seen_wifi_clients[mac] = client
|
||||
|
||||
rssi_val = client.get('rssi_current')
|
||||
if rssi_val is None:
|
||||
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
||||
|
||||
client_device = {
|
||||
'mac': mac,
|
||||
'vendor': client.get('vendor'),
|
||||
'name': client.get('vendor') or 'WiFi Client',
|
||||
'rssi': rssi_val,
|
||||
'associated_bssid': client.get('associated_bssid'),
|
||||
'probed_ssids': client.get('probed_ssids', []),
|
||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||
'is_client': True,
|
||||
}
|
||||
|
||||
if self._tscm_correlation:
|
||||
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
||||
client_device['classification'] = profile.risk_level.value
|
||||
client_device['score'] = profile.total_score
|
||||
client_device['score_modifier'] = profile.score_modifier
|
||||
client_device['known_device'] = profile.known_device
|
||||
client_device['known_device_name'] = profile.known_device_name
|
||||
client_device['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
]
|
||||
client_device['recommended_action'] = profile.recommended_action
|
||||
|
||||
self.tscm_wifi_clients[mac] = client_device
|
||||
except Exception as e:
|
||||
logger.debug(f"WiFi client scan error: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"WiFi scan error: {e}")
|
||||
|
||||
@@ -3289,6 +3384,9 @@ class ModeManager:
|
||||
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
||||
enriched['classification'] = profile.risk_level.value
|
||||
enriched['score'] = profile.total_score
|
||||
enriched['score_modifier'] = profile.score_modifier
|
||||
enriched['known_device'] = profile.known_device
|
||||
enriched['known_device_name'] = profile.known_device_name
|
||||
enriched['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
@@ -3304,7 +3402,11 @@ class ModeManager:
|
||||
try:
|
||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
||||
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
||||
rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check)
|
||||
rf_signals = _scan_rf_signals(
|
||||
sdr_device,
|
||||
stop_check=agent_stop_check,
|
||||
sweep_ranges=sweep_ranges
|
||||
)
|
||||
|
||||
# Analyze each RF signal like local mode does
|
||||
analyzed_signals = []
|
||||
@@ -3328,6 +3430,9 @@ class ModeManager:
|
||||
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
||||
analyzed['classification'] = profile.risk_level.value
|
||||
analyzed['score'] = profile.total_score
|
||||
analyzed['score_modifier'] = profile.score_modifier
|
||||
analyzed['known_device'] = profile.known_device
|
||||
analyzed['known_device_name'] = profile.known_device_name
|
||||
analyzed['indicators'] = [
|
||||
{'type': i.type.value, 'desc': i.description}
|
||||
for i in profile.indicators
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.12.1"
|
||||
version = "2.21.1"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = {text = "MIT"}
|
||||
license = {text = "Apache-2.0"}
|
||||
authors = [
|
||||
{name = "Intercept Contributors"}
|
||||
]
|
||||
@@ -14,7 +14,7 @@ classifiers = [
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Flask",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: MacOS",
|
||||
"Programming Language :: Python :: 3",
|
||||
@@ -33,6 +33,7 @@ dependencies = [
|
||||
"flask-limiter>=2.5.4",
|
||||
"bleak>=0.21.0",
|
||||
"flask-sock",
|
||||
"websocket-client>=1.6.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
@@ -52,6 +53,16 @@ dev = [
|
||||
"types-flask>=1.1.0",
|
||||
]
|
||||
|
||||
optionals = [
|
||||
"scipy>=1.10.0",
|
||||
"qrcode[pil]>=7.4",
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=9.0.0",
|
||||
"meshtastic>=2.0.0",
|
||||
"psycopg2-binary>=2.9.9",
|
||||
"scapy>=2.4.5",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
intercept = "intercept:main"
|
||||
|
||||
|
||||
@@ -13,10 +13,13 @@ bleak>=0.21.0
|
||||
# Satellite tracking (optional - only needed for satellite features)
|
||||
skyfield>=1.45
|
||||
|
||||
# DSC decoding (optional - only needed for VHF DSC maritime distress)
|
||||
# DSC decoding and SSTV decoding (DSP pipeline)
|
||||
scipy>=1.10.0
|
||||
numpy>=1.24.0
|
||||
|
||||
# SSTV image output (optional - needed for SSTV image decoding)
|
||||
Pillow>=9.0.0
|
||||
|
||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||
pyserial>=3.5
|
||||
|
||||
@@ -29,10 +32,15 @@ scapy>=2.4.5
|
||||
# QR code generation for Meshtastic channels (optional)
|
||||
qrcode[pil]>=7.4
|
||||
|
||||
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
websocket-client>=1.6.0
|
||||
|
||||
@@ -2,30 +2,40 @@
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .acars import acars_bp
|
||||
from .adsb import adsb_bp
|
||||
from .ais import ais_bp
|
||||
from .dsc import dsc_bp
|
||||
from .acars import acars_bp
|
||||
from .alerts import alerts_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 .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .controller import controller_bp
|
||||
from .correlation import correlation_bp
|
||||
from .dsc import dsc_bp
|
||||
from .gps import gps_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .offline import offline_bp
|
||||
from .updater import updater_bp
|
||||
from .pager import pager_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 .tscm import init_tscm_state, tscm_bp
|
||||
from .updater import updater_bp
|
||||
from .vdl2 import vdl2_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .websdr import websdr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -38,12 +48,13 @@ def register_blueprints(app):
|
||||
app.register_blueprint(ais_bp)
|
||||
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||
app.register_blueprint(acars_bp)
|
||||
app.register_blueprint(vdl2_bp)
|
||||
app.register_blueprint(aprs_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
@@ -51,6 +62,15 @@ def register_blueprints(app):
|
||||
app.register_blueprint(offline_bp) # Offline mode settings
|
||||
app.register_blueprint(updater_bp) # GitHub update checking
|
||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
@@ -20,23 +20,23 @@ from flask import Blueprint, jsonify, request, Response
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
'131.725', # North America
|
||||
'131.825', # North America
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
@@ -126,6 +126,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_acars_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -144,9 +151,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global acars_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.acars_lock:
|
||||
app_module.acars_process = None
|
||||
# Release SDR device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
acars_active_device = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
@@ -233,12 +255,22 @@ def start_acars() -> Response:
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build acarsdec command
|
||||
# Different forks have different syntax:
|
||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||
# Note: gain/ppm must come BEFORE -r
|
||||
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
|
||||
# Note: gain/ppm must come BEFORE -r/-d
|
||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||
cmd = [acarsdec_path]
|
||||
if json_flag == '--output':
|
||||
@@ -249,21 +281,33 @@ def start_acars() -> Response:
|
||||
else:
|
||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||
|
||||
# Add gain if not auto (must be before -r)
|
||||
# Add gain if not auto (must be before -r/-d)
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if specified (must be before -r)
|
||||
# Add PPM correction if specified (must be before -r/-d)
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Add device and frequencies
|
||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
||||
if json_flag == '--output':
|
||||
if is_soapy:
|
||||
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
device_str = builder._build_device_string(sdr_device)
|
||||
if json_flag == '--output':
|
||||
cmd.extend(['-m', '256'])
|
||||
cmd.extend(['--soapysdr', device_str])
|
||||
else:
|
||||
cmd.extend(['-d', device_str])
|
||||
elif json_flag == '--output':
|
||||
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
|
||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||
cmd.extend(['-m', '256'])
|
||||
cmd.extend(['--rtlsdr', str(device)])
|
||||
else:
|
||||
# TLeconte fork RTL-SDR: -r <device>
|
||||
cmd.extend(['-r', str(device)])
|
||||
cmd.extend(frequencies)
|
||||
|
||||
@@ -311,6 +355,7 @@ def start_acars() -> Response:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
app_module.acars_process = process
|
||||
register_process(process)
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
@@ -366,27 +411,25 @@ def stop_acars() -> Response:
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.acars_queue,
|
||||
channel_key='acars',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@acars_bp.route('/frequencies')
|
||||
@@ -395,7 +438,7 @@ def get_frequencies() -> Response:
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'north_america': ['131.725', '131.825'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
|
||||
@@ -38,11 +38,13 @@ from config import (
|
||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
|
||||
from utils.validation import (
|
||||
validate_device_index, validate_gain,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
ADSB_SBS_PORT,
|
||||
@@ -75,6 +77,11 @@ _sbs_error_logged = False # Suppress repeated connection error logs
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
|
||||
# Per-client SSE queues for ADS-B stream fanout.
|
||||
_adsb_stream_subscribers: set[queue.Queue] = set()
|
||||
_adsb_stream_subscribers_lock = threading.Lock()
|
||||
_ADSB_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
# Load aircraft database at module init
|
||||
aircraft_db.load_database()
|
||||
|
||||
@@ -201,6 +208,31 @@ def _parse_int_param(value: str | None, default: int, min_value: int | None = No
|
||||
return parsed
|
||||
|
||||
|
||||
def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
|
||||
"""Fan out a payload to all active ADS-B SSE subscribers."""
|
||||
with _adsb_stream_subscribers_lock:
|
||||
subscribers = tuple(_adsb_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
# Drop oldest queued event for that client and try once more.
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
# Client queue remains saturated; skip this payload.
|
||||
continue
|
||||
|
||||
|
||||
def _adsb_stream_queue_depth() -> int:
|
||||
"""Best-effort aggregate queue depth across connected ADS-B SSE clients."""
|
||||
with _adsb_stream_subscribers_lock:
|
||||
subscribers = tuple(_adsb_stream_subscribers)
|
||||
return sum(subscriber.qsize() for subscriber in subscribers)
|
||||
|
||||
|
||||
def _get_active_session() -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||
return None
|
||||
@@ -347,10 +379,62 @@ def parse_sbs_stream(service_addr):
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
def flush_pending_updates(force: bool = False) -> None:
|
||||
nonlocal last_update
|
||||
if not pending_updates:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
if not force and now - last_update < ADSB_UPDATE_INTERVAL:
|
||||
return
|
||||
|
||||
captured_at = datetime.now(timezone.utc)
|
||||
for update_icao in tuple(pending_updates):
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
snapshot = app_module.adsb_aircraft[update_icao]
|
||||
_broadcast_adsb_update({
|
||||
'type': 'aircraft',
|
||||
**snapshot
|
||||
})
|
||||
adsb_snapshot_writer.enqueue({
|
||||
'captured_at': captured_at,
|
||||
'icao': update_icao,
|
||||
'callsign': snapshot.get('callsign'),
|
||||
'registration': snapshot.get('registration'),
|
||||
'type_code': snapshot.get('type_code'),
|
||||
'type_desc': snapshot.get('type_desc'),
|
||||
'altitude': snapshot.get('altitude'),
|
||||
'speed': snapshot.get('speed'),
|
||||
'heading': snapshot.get('heading'),
|
||||
'vertical_rate': snapshot.get('vertical_rate'),
|
||||
'lat': snapshot.get('lat'),
|
||||
'lon': snapshot.get('lon'),
|
||||
'squawk': snapshot.get('squawk'),
|
||||
'source_host': service_addr,
|
||||
'snapshot': snapshot,
|
||||
})
|
||||
# Geofence check
|
||||
_gf_lat = snapshot.get('lat')
|
||||
_gf_lon = snapshot.get('lon')
|
||||
if _gf_lat is not None and _gf_lon is not None:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||
):
|
||||
process_event('adsb', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
flush_pending_updates(force=True)
|
||||
logger.warning("SBS connection closed (no data)")
|
||||
break
|
||||
adsb_bytes_received += len(data)
|
||||
@@ -437,6 +521,12 @@ def parse_sbs_stream(service_addr):
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
if abs(aircraft['vertical_rate']) > 4000:
|
||||
process_event('adsb', {
|
||||
'type': 'vertical_rate_anomaly', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'vertical_rate': aircraft['vertical_rate'],
|
||||
}, 'vertical_rate_anomaly')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -454,44 +544,49 @@ def parse_sbs_stream(service_addr):
|
||||
elif msg_type == '6' and len(parts) > 17:
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
sq = parts[17].strip()
|
||||
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
|
||||
if sq in _EMERGENCY_SQUAWKS:
|
||||
process_event('adsb', {
|
||||
'type': 'squawk_emergency', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||
}, 'squawk_emergency')
|
||||
|
||||
elif msg_type == '2' and len(parts) > 15:
|
||||
if parts[11]:
|
||||
try:
|
||||
aircraft['altitude'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[12]:
|
||||
try:
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[13]:
|
||||
try:
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[14] and parts[15]:
|
||||
try:
|
||||
aircraft['lat'] = float(parts[14])
|
||||
aircraft['lon'] = float(parts[15])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
app_module.adsb_aircraft.set(icao, aircraft)
|
||||
pending_updates.add(icao)
|
||||
adsb_messages_received += 1
|
||||
adsb_last_message_time = time.time()
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
snapshot = app_module.adsb_aircraft[update_icao]
|
||||
app_module.adsb_queue.put({
|
||||
'type': 'aircraft',
|
||||
**snapshot
|
||||
})
|
||||
adsb_snapshot_writer.enqueue({
|
||||
'captured_at': datetime.now(timezone.utc),
|
||||
'icao': update_icao,
|
||||
'callsign': snapshot.get('callsign'),
|
||||
'registration': snapshot.get('registration'),
|
||||
'type_code': snapshot.get('type_code'),
|
||||
'type_desc': snapshot.get('type_desc'),
|
||||
'altitude': snapshot.get('altitude'),
|
||||
'speed': snapshot.get('speed'),
|
||||
'heading': snapshot.get('heading'),
|
||||
'vertical_rate': snapshot.get('vertical_rate'),
|
||||
'lat': snapshot.get('lat'),
|
||||
'lon': snapshot.get('lon'),
|
||||
'squawk': snapshot.get('squawk'),
|
||||
'source_host': service_addr,
|
||||
'snapshot': snapshot,
|
||||
})
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
flush_pending_updates()
|
||||
|
||||
except socket.timeout:
|
||||
flush_pending_updates()
|
||||
continue
|
||||
|
||||
flush_pending_updates(force=True)
|
||||
sock.close()
|
||||
adsb_connected = False
|
||||
except OSError as e:
|
||||
@@ -551,7 +646,7 @@ def adsb_status():
|
||||
'last_message_time': adsb_last_message_time,
|
||||
'aircraft_count': len(app_module.adsb_aircraft),
|
||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||
'queue_size': app_module.adsb_queue.qsize(),
|
||||
'queue_size': _adsb_stream_queue_depth(),
|
||||
'dump1090_path': find_dump1090(),
|
||||
'dump1090_running': dump1090_running,
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
@@ -632,6 +727,9 @@ def start_adsb():
|
||||
'session': session
|
||||
})
|
||||
|
||||
# Kill any stale app-spawned dump1090 from a previous run before checking the port
|
||||
cleanup_stale_dump1090()
|
||||
|
||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||
existing_service = check_dump1090_service()
|
||||
if existing_service:
|
||||
@@ -684,6 +782,7 @@ def start_adsb():
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
clear_dump1090_pid()
|
||||
logger.info("Killed stale ADS-B process")
|
||||
|
||||
# Check if device is available before starting local dump1090
|
||||
@@ -720,6 +819,7 @@ def start_adsb():
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
write_dump1090_pid(app_module.adsb_process.pid)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
|
||||
@@ -732,16 +832,43 @@ def start_adsb():
|
||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
# Parse stderr to provide specific guidance
|
||||
error_type = 'START_FAILED'
|
||||
stderr_lower = stderr_output.lower()
|
||||
|
||||
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
|
||||
error_msg = 'SDR device is busy. Another process may be using it.'
|
||||
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
|
||||
error_type = 'DEVICE_BUSY'
|
||||
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
|
||||
error_msg = 'RTL-SDR device not found.'
|
||||
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
|
||||
error_type = 'DEVICE_NOT_FOUND'
|
||||
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
|
||||
error_msg = 'Kernel DVB-T driver is blocking the device.'
|
||||
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
|
||||
error_type = 'KERNEL_DRIVER'
|
||||
elif 'permission' in stderr_lower or 'access' in stderr_lower:
|
||||
error_msg = 'Permission denied accessing RTL-SDR device.'
|
||||
suggestion = 'Run Intercept with sudo, or add udev rules for RTL-SDR devices.'
|
||||
error_type = 'PERMISSION_DENIED'
|
||||
elif sdr_type == SDRType.RTL_SDR:
|
||||
error_msg = 'dump1090 failed to start.'
|
||||
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
|
||||
else:
|
||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.'
|
||||
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||
|
||||
full_msg = f'{error_msg} {suggestion}'
|
||||
if stderr_output and len(stderr_output) < 300:
|
||||
full_msg += f' (Details: {stderr_output})'
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': error_type,
|
||||
'message': full_msg
|
||||
})
|
||||
|
||||
adsb_using_service = True
|
||||
adsb_active_device = device # Track which device is being used
|
||||
@@ -791,6 +918,7 @@ def stop_adsb():
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
clear_dump1090_pid()
|
||||
logger.info("ADS-B process stopped")
|
||||
|
||||
# Release device from registry
|
||||
@@ -809,19 +937,39 @@ def stop_adsb():
|
||||
@adsb_bp.route('/stream')
|
||||
def stream_adsb():
|
||||
"""SSE stream for ADS-B aircraft."""
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_ADSB_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _adsb_stream_subscribers_lock:
|
||||
_adsb_stream_subscribers.add(client_queue)
|
||||
|
||||
# Prime new clients with current known aircraft so they don't wait for the
|
||||
# next positional update before rendering.
|
||||
for snapshot in list(app_module.adsb_aircraft.values()):
|
||||
try:
|
||||
client_queue.put_nowait({'type': 'aircraft', **snapshot})
|
||||
except queue.Full:
|
||||
break
|
||||
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('adsb', 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
|
||||
finally:
|
||||
with _adsb_stream_subscribers_lock:
|
||||
_adsb_stream_subscribers.discard(client_queue)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
@@ -832,10 +980,12 @@ def stream_adsb():
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'adsb_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
adsb_auto_start=ADSB_AUTO_START,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
|
||||
@@ -1064,3 +1214,17 @@ def aircraft_photo(registration: str):
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft/<icao>/messages')
|
||||
def get_aircraft_messages(icao: str):
|
||||
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
||||
callsign = aircraft.get('callsign') if aircraft else None
|
||||
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
|
||||
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||
|
||||
@@ -18,7 +18,8 @@ import app as app_module
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.logging import get_logger
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
AIS_TCP_PORT,
|
||||
@@ -123,13 +124,27 @@ def parse_ais_stream(port: int):
|
||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||
for mmsi in pending_updates:
|
||||
if mmsi in app_module.ais_vessels:
|
||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||
try:
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**app_module.ais_vessels[mmsi]
|
||||
**_vessel_snap
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Geofence check
|
||||
_v_lat = _vessel_snap.get('lat')
|
||||
_v_lon = _vessel_snap.get('lon')
|
||||
if _v_lat and _v_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
mmsi, 'vessel', _v_lat, _v_lon,
|
||||
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
||||
):
|
||||
process_event('ais', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -281,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
||||
# Timestamp
|
||||
vessel['last_seen'] = time.time()
|
||||
|
||||
# Check for DSC DISTRESS matching this MMSI
|
||||
try:
|
||||
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||
vessel['dsc_distress'] = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return vessel
|
||||
|
||||
|
||||
@@ -477,30 +502,47 @@ def stop_ais():
|
||||
@ais_bp.route('/stream')
|
||||
def stream_ais():
|
||||
"""SSE stream for AIS vessels."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('ais', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.ais_queue,
|
||||
channel_key='ais',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||
def get_vessel_dsc(mmsi: str):
|
||||
"""Get DSC messages associated with a vessel MMSI."""
|
||||
if not mmsi or not mmsi.isdigit():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||
|
||||
matches = []
|
||||
try:
|
||||
for key, msg in app_module.dsc_messages.items():
|
||||
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||
matches.append(dict(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'ais_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Alerting API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.alerts import get_alert_manager
|
||||
from utils.sse import format_sse
|
||||
|
||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||
|
||||
|
||||
@alerts_bp.route('/rules', methods=['GET'])
|
||||
def list_rules():
|
||||
manager = get_alert_manager()
|
||||
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
||||
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
|
||||
|
||||
|
||||
@alerts_bp.route('/rules', methods=['POST'])
|
||||
def create_rule():
|
||||
data = request.get_json() or {}
|
||||
if not isinstance(data.get('match', {}), dict):
|
||||
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
|
||||
|
||||
manager = get_alert_manager()
|
||||
rule_id = manager.add_rule(data)
|
||||
return jsonify({'status': 'success', 'rule_id': rule_id})
|
||||
|
||||
|
||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
||||
def update_rule(rule_id: int):
|
||||
data = request.get_json() or {}
|
||||
manager = get_alert_manager()
|
||||
ok = manager.update_rule(rule_id, data)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
|
||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||
def delete_rule(rule_id: int):
|
||||
manager = get_alert_manager()
|
||||
ok = manager.delete_rule(rule_id)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
|
||||
@alerts_bp.route('/events', methods=['GET'])
|
||||
def list_events():
|
||||
manager = get_alert_manager()
|
||||
limit = request.args.get('limit', default=100, type=int)
|
||||
mode = request.args.get('mode')
|
||||
severity = request.args.get('severity')
|
||||
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
||||
return jsonify({'status': 'success', 'events': events})
|
||||
|
||||
|
||||
@alerts_bp.route('/stream', methods=['GET'])
|
||||
def stream_alerts() -> Response:
|
||||
manager = get_alert_manager()
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
for event in manager.stream_events(timeout=1.0):
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@@ -13,7 +13,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from subprocess import DEVNULL, PIPE, STDOUT
|
||||
from subprocess import PIPE, STDOUT
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
@@ -21,7 +21,9 @@ from flask import Blueprint, jsonify, request, Response
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
@@ -31,6 +33,9 @@ from utils.constants import (
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# Track which SDR device is being used
|
||||
aprs_active_device: int | None = None
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
'north_america': '144.390',
|
||||
@@ -42,6 +47,8 @@ APRS_FREQUENCIES = {
|
||||
'brazil': '145.570',
|
||||
'japan': '144.640',
|
||||
'china': '144.640',
|
||||
'iss': '145.825',
|
||||
'sonate2': '145.825',
|
||||
}
|
||||
|
||||
# Statistics
|
||||
@@ -49,6 +56,7 @@ aprs_packet_count = 0
|
||||
aprs_station_count = 0
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {} # callsign -> station data
|
||||
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
|
||||
|
||||
# Meter rate limiting
|
||||
_last_meter_time = 0.0
|
||||
@@ -72,6 +80,11 @@ def find_rtl_fm() -> Optional[str]:
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
@@ -1301,7 +1314,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
|
||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||
rtl_fm's stderr is sent to DEVNULL for the same reason.
|
||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
||||
|
||||
Outputs two types of messages to the queue:
|
||||
- type='aprs': Decoded APRS packets
|
||||
@@ -1367,6 +1380,26 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet.get('lat')
|
||||
_aprs_lon = packet.get('lon')
|
||||
if _aprs_lat and _aprs_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
@@ -1383,6 +1416,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global aprs_active_device
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1394,20 +1428,27 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_rtl_fm and (has_direwolf or has_multimon),
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
@@ -1441,6 +1482,7 @@ def get_stations() -> Response:
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
@@ -1449,14 +1491,6 @@ def start_aprs() -> Response:
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for required tools
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
@@ -1477,6 +1511,35 @@ def start_aprs() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = device
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||
@@ -1497,28 +1560,29 @@ def start_aprs() -> Response:
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
|
||||
freq_hz = f"{float(frequency)}M"
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', freq_hz,
|
||||
'-M', 'nfm', # Narrowband FM for APRS
|
||||
'-s', '22050', # Sample rate matching direwolf -r 22050
|
||||
'-E', 'dc', # Enable DC blocking filter for cleaner audio
|
||||
'-A', 'fast', # Fast AGC for packet bursts
|
||||
'-d', str(device),
|
||||
]
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
# Gain: 0 means auto, otherwise set specific gain
|
||||
if gain and str(gain) != '0':
|
||||
rtl_cmd.extend(['-g', str(gain)])
|
||||
|
||||
# PPM frequency correction
|
||||
if ppm and str(ppm) != '0':
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Output raw audio to stdout
|
||||
rtl_cmd.append('-')
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
@@ -1552,15 +1616,25 @@ def start_aprs() -> Response:
|
||||
|
||||
try:
|
||||
# Start rtl_fm with stdout piped to decoder.
|
||||
# stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr).
|
||||
# stderr is captured via PIPE so errors are reported to the user.
|
||||
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=PIPE,
|
||||
stderr=DEVNULL,
|
||||
stderr=PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text:
|
||||
logger.debug(f"[RTL_FM] {err_text}")
|
||||
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||
rtl_stderr_thread.start()
|
||||
|
||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||
# Use text mode with line buffering for reliable line-by-line reading.
|
||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||
@@ -1582,13 +1656,25 @@ def start_aprs() -> Response:
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if rtl_process.poll() is not None:
|
||||
# rtl_fm exited early - something went wrong
|
||||
# rtl_fm exited early - capture stderr for diagnostics
|
||||
stderr_output = ''
|
||||
try:
|
||||
remaining = rtl_process.stderr.read()
|
||||
if remaining:
|
||||
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:200]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
if decoder_process.poll() is not None:
|
||||
@@ -1602,6 +1688,9 @@ def start_aprs() -> Response:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store references for status checks and cleanup
|
||||
@@ -1621,17 +1710,23 @@ def start_aprs() -> Response:
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
|
||||
@@ -1660,30 +1755,33 @@ def stop_aprs() -> Response:
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.aprs_queue,
|
||||
channel_key='aprs',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@aprs_bp.route('/frequencies')
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""WebSocket-based audio streaming for SDR."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import shutil
|
||||
import json
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
@@ -36,11 +37,17 @@ def find_rtl_fm():
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def _rtl_fm_demod_mode(modulation):
|
||||
"""Map UI modulation names to rtl_fm demod tokens."""
|
||||
mod = str(modulation or '').lower().strip()
|
||||
return 'wbfm' if mod == 'wfm' else mod
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
"""Kill any running audio processes."""
|
||||
global audio_process, rtl_process
|
||||
|
||||
@@ -66,12 +73,6 @@ def kill_audio_processes():
|
||||
pass
|
||||
rtl_process = None
|
||||
|
||||
# Kill any orphaned processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
|
||||
except:
|
||||
pass
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
@@ -109,14 +110,14 @@ def start_audio_stream(config):
|
||||
|
||||
freq_hz = int(freq * 1e6)
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', _rtl_fm_demod_mode(mod),
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', str(squelch),
|
||||
]
|
||||
|
||||
@@ -229,7 +230,11 @@ def init_audio_websocket(app: Flask):
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if "timed out" not in str(e).lower():
|
||||
msg = str(e).lower()
|
||||
if "connection closed" in msg:
|
||||
logger.info("WebSocket closed by client")
|
||||
break
|
||||
if "timed out" not in msg:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
|
||||
# Stream audio data if active
|
||||
@@ -253,4 +258,19 @@ def init_audio_websocket(app: Flask):
|
||||
finally:
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream.
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("WebSocket audio client disconnected")
|
||||
|
||||
@@ -18,10 +18,11 @@ from typing import Any, Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
@@ -552,26 +553,23 @@ def get_bt_devices():
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -11,6 +11,8 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
@@ -28,12 +30,18 @@ from utils.bluetooth import (
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||
|
||||
# Blueprint
|
||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||
|
||||
# Seen-before tracking
|
||||
_bt_seen_cache: set[str] = set()
|
||||
_bt_session_seen: set[str] = set()
|
||||
_bt_seen_lock = threading.Lock()
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE FUNCTIONS
|
||||
# =============================================================================
|
||||
@@ -173,6 +181,13 @@ def save_observation_history(device: BTDeviceAggregate) -> None:
|
||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||
|
||||
|
||||
def load_seen_device_ids() -> set[str]:
|
||||
"""Load distinct device IDs from history for seen-before tracking."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
||||
return {row['device_id'] for row in cursor}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API ENDPOINTS
|
||||
# =============================================================================
|
||||
@@ -214,13 +229,35 @@ def start_scan():
|
||||
rssi_threshold = data.get('rssi_threshold', -100)
|
||||
|
||||
# Validate mode
|
||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||
if mode not in valid_modes:
|
||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||
|
||||
# Get scanner instance
|
||||
scanner = get_bluetooth_scanner(adapter_id)
|
||||
|
||||
# Initialize database tables if needed
|
||||
init_bt_tables()
|
||||
|
||||
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
||||
try:
|
||||
with _bt_seen_lock:
|
||||
device.seen_before = device.device_id in _bt_seen_cache
|
||||
if device.device_id not in _bt_session_seen:
|
||||
save_observation_history(device)
|
||||
_bt_session_seen.add(device.device_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"BT seen-before update failed: {e}")
|
||||
|
||||
# Setup seen-before callback
|
||||
if _handle_seen_before not in scanner._on_device_updated_callbacks:
|
||||
scanner.add_device_callback(_handle_seen_before)
|
||||
|
||||
# Ensure cache is initialized
|
||||
with _bt_seen_lock:
|
||||
if not _bt_seen_cache:
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
@@ -228,8 +265,11 @@ def start_scan():
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
# Initialize database tables if needed
|
||||
init_bt_tables()
|
||||
# Refresh seen-before cache and reset session set for a new scan
|
||||
with _bt_seen_lock:
|
||||
_bt_seen_cache.clear()
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
_bt_session_seen.clear()
|
||||
|
||||
# Load active baseline if exists
|
||||
baseline_id = get_active_baseline_id()
|
||||
@@ -860,6 +900,10 @@ def stream_events():
|
||||
"""Generate SSE events from scanner."""
|
||||
for event in scanner.stream_events(timeout=1.0):
|
||||
event_name, event_data = map_event_type(event)
|
||||
try:
|
||||
process_event('bluetooth', event_data, event_name)
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event_data, event=event_name)
|
||||
|
||||
return Response(
|
||||
@@ -947,6 +991,17 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
# Convert to TSCM format with tracker detection data
|
||||
tscm_devices = []
|
||||
for device in devices:
|
||||
manufacturer_name = device.manufacturer_name
|
||||
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_name = oui_vendor
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
device_data = {
|
||||
'mac': device.address,
|
||||
'address_type': device.address_type,
|
||||
@@ -956,7 +1011,7 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
'rssi_median': device.rssi_median,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'type': _classify_device_type(device),
|
||||
'manufacturer': device.manufacturer_name,
|
||||
'manufacturer': manufacturer_name,
|
||||
'manufacturer_id': device.manufacturer_id,
|
||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||
'protocol': device.protocol,
|
||||
@@ -1178,6 +1233,30 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
"""Classify device type from available data."""
|
||||
name_lower = (device.name or '').lower()
|
||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||
service_uuids = device.service_uuids or []
|
||||
|
||||
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_lower = oui_vendor.lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def normalize_uuid(uuid: str) -> str:
|
||||
if not uuid:
|
||||
return ''
|
||||
value = str(uuid).lower().strip()
|
||||
if value.startswith('0x'):
|
||||
value = value[2:]
|
||||
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
||||
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
||||
return value[4:8]
|
||||
if len(value) == 4:
|
||||
return value
|
||||
return value
|
||||
|
||||
# Check by name patterns
|
||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||
@@ -1197,6 +1276,29 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||
return 'media'
|
||||
|
||||
# Tracker signals (metadata or Find My service)
|
||||
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
||||
return 'tracker'
|
||||
|
||||
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
||||
if 'fd6f' in normalized_uuids:
|
||||
return 'tracker'
|
||||
|
||||
# Service UUIDs (GATT / classic)
|
||||
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
||||
wearable_uuids = {'180d', '1814', '1816'}
|
||||
hid_uuids = {'1812'}
|
||||
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
||||
|
||||
if normalized_uuids & audio_uuids:
|
||||
return 'audio'
|
||||
if normalized_uuids & hid_uuids:
|
||||
return 'peripheral'
|
||||
if normalized_uuids & wearable_uuids:
|
||||
return 'wearable'
|
||||
if normalized_uuids & beacon_uuids:
|
||||
return 'beacon'
|
||||
|
||||
# Check by manufacturer
|
||||
if 'apple' in manufacturer_lower:
|
||||
return 'apple_device'
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
BT Locate — Bluetooth SAR Device Location Flask Blueprint.
|
||||
|
||||
Provides endpoints for managing locate sessions, streaming detection events,
|
||||
and retrieving GPS-tagged signal trails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.bluetooth.irk_extractor import get_paired_irks
|
||||
from utils.bt_locate import (
|
||||
Environment,
|
||||
LocateTarget,
|
||||
get_locate_session,
|
||||
resolve_rpa,
|
||||
start_locate_session,
|
||||
stop_locate_session,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
|
||||
logger = logging.getLogger('intercept.bt_locate')
|
||||
|
||||
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
|
||||
|
||||
|
||||
@bt_locate_bp.route('/start', methods=['POST'])
|
||||
def start_session():
|
||||
"""
|
||||
Start a locate session.
|
||||
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
|
||||
Returns:
|
||||
JSON with session status.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Build target
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
device_key=data.get('device_key'),
|
||||
fingerprint_id=data.get('fingerprint_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
|
||||
# At least one identifier required
|
||||
if not any([
|
||||
target.mac_address,
|
||||
target.name_pattern,
|
||||
target.irk_hex,
|
||||
target.device_id,
|
||||
target.device_key,
|
||||
target.fingerprint_id,
|
||||
]):
|
||||
return jsonify({
|
||||
'error': (
|
||||
'At least one target identifier required '
|
||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
|
||||
)
|
||||
}), 400
|
||||
|
||||
# Parse environment
|
||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||
|
||||
custom_exponent = data.get('custom_exponent')
|
||||
if custom_exponent is not None:
|
||||
try:
|
||||
custom_exponent = float(custom_exponent)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'custom_exponent must be a number'}), 400
|
||||
|
||||
# Fallback coordinates when GPS is unavailable (from user settings)
|
||||
fallback_lat = None
|
||||
fallback_lon = None
|
||||
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
|
||||
try:
|
||||
fallback_lat = float(data['fallback_lat'])
|
||||
fallback_lon = float(data['fallback_lon'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"Starting locate session: target={target.to_dict()}, "
|
||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||
)
|
||||
|
||||
try:
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.',
|
||||
}), 503
|
||||
except Exception as exc:
|
||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Failed to start locate session',
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||
def stop_session():
|
||||
"""Stop the active locate session."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'status': 'no_session'})
|
||||
|
||||
stop_locate_session()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||
return jsonify(session.get_status(include_debug=include_debug))
|
||||
|
||||
|
||||
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||
def get_trail():
|
||||
"""Get detection trail data."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'trail': [], 'gps_trail': []})
|
||||
|
||||
return jsonify({
|
||||
'trail': session.get_trail(),
|
||||
'gps_trail': session.get_gps_trail(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stream', methods=['GET'])
|
||||
def stream_detections():
|
||||
"""SSE stream of detection events."""
|
||||
|
||||
def event_generator() -> Generator[str, None, None]:
|
||||
while True:
|
||||
# Re-fetch session each iteration in case it changes
|
||||
s = get_locate_session()
|
||||
if not s:
|
||||
yield format_sse({'type': 'session_ended'}, event='session_ended')
|
||||
return
|
||||
|
||||
try:
|
||||
event = s.event_queue.get(timeout=2.0)
|
||||
yield format_sse(event, event='detection')
|
||||
except Exception:
|
||||
yield format_sse({}, event='ping')
|
||||
|
||||
return Response(
|
||||
event_generator(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
|
||||
def test_resolve_rpa():
|
||||
"""
|
||||
Test if an IRK resolves to a given address.
|
||||
|
||||
Request JSON:
|
||||
- irk_hex: 16-byte IRK as hex string
|
||||
- address: BLE address string
|
||||
|
||||
Returns:
|
||||
JSON with resolution result.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
irk_hex = data.get('irk_hex', '')
|
||||
address = data.get('address', '')
|
||||
|
||||
if not irk_hex or not address:
|
||||
return jsonify({'error': 'irk_hex and address are required'}), 400
|
||||
|
||||
try:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
||||
|
||||
if len(irk) != 16:
|
||||
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
|
||||
|
||||
result = resolve_rpa(irk, address)
|
||||
return jsonify({
|
||||
'resolved': result,
|
||||
'irk_hex': irk_hex,
|
||||
'address': address,
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/environment', methods=['POST'])
|
||||
def set_environment():
|
||||
"""Update the environment on the active session."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no active session'}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
env_str = data.get('environment', '').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||
|
||||
custom_exponent = data.get('custom_exponent')
|
||||
if custom_exponent is not None:
|
||||
try:
|
||||
custom_exponent = float(custom_exponent)
|
||||
except (ValueError, TypeError):
|
||||
custom_exponent = None
|
||||
|
||||
session.set_environment(environment, custom_exponent)
|
||||
return jsonify({
|
||||
'status': 'updated',
|
||||
'environment': environment.name,
|
||||
'path_loss_exponent': session.estimator.n,
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/debug', methods=['GET'])
|
||||
def debug_matching():
|
||||
"""Debug endpoint showing scanner devices and match results."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'error': 'no session'})
|
||||
|
||||
scanner = session._scanner
|
||||
if not scanner:
|
||||
return jsonify({'error': 'no scanner'})
|
||||
|
||||
devices = scanner.get_devices(max_age_seconds=30)
|
||||
return jsonify({
|
||||
'target': session.target.to_dict(),
|
||||
'device_count': len(devices),
|
||||
'devices': [
|
||||
{
|
||||
'device_id': d.device_id,
|
||||
'address': d.address,
|
||||
'name': d.name,
|
||||
'rssi': d.rssi_current,
|
||||
'matches': session.target.matches(d),
|
||||
}
|
||||
for d in devices
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/paired_irks', methods=['GET'])
|
||||
def paired_irks():
|
||||
"""Return paired Bluetooth devices that have IRKs."""
|
||||
try:
|
||||
devices = get_paired_irks()
|
||||
except Exception as e:
|
||||
logger.exception("Failed to read paired IRKs")
|
||||
return jsonify({'devices': [], 'error': str(e)})
|
||||
|
||||
return jsonify({'devices': devices})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/clear_trail', methods=['POST'])
|
||||
def clear_trail():
|
||||
"""Clear the detection trail."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({'status': 'no_session'})
|
||||
|
||||
session.clear_trail()
|
||||
return jsonify({'status': 'cleared'})
|
||||
@@ -10,14 +10,17 @@ This blueprint provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
@@ -34,10 +37,28 @@ from utils.trilateration import (
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent data queue for combined SSE stream
|
||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||
with _agent_stream_subscribers_lock:
|
||||
subscribers = tuple(_agent_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
continue
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -450,12 +471,12 @@ def proxy_mode_status(agent_id: int, mode: str):
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -473,18 +494,59 @@ def proxy_mode_data(agent_id: int, mode: str):
|
||||
'data': result
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||
def proxy_wifi_monitor(agent_id: int):
|
||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||
def proxy_mode_stream(agent_id: int, mode: str):
|
||||
"""Proxy SSE stream from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
query = request.query_string.decode('utf-8')
|
||||
url = f"{client.base_url}/{mode}/stream"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
headers = {'Accept': 'text/event-stream'}
|
||||
if agent.get('api_key'):
|
||||
headers['X-API-Key'] = agent['api_key']
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
continue
|
||||
yield chunk.decode('utf-8', errors='ignore')
|
||||
except Exception as e:
|
||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||
yield format_sse({
|
||||
'type': 'error',
|
||||
'message': str(e),
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
})
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||
def proxy_wifi_monitor(agent_id: int):
|
||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
data = request.json or {}
|
||||
@@ -582,19 +644,16 @@ def ingest_push_data():
|
||||
received_at=data.get('received_at')
|
||||
)
|
||||
|
||||
# Emit to SSE stream
|
||||
try:
|
||||
agent_data_queue.put_nowait({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
except queue.Full:
|
||||
logger.warning("Agent data queue full, data may be lost")
|
||||
# Emit to SSE stream (fanout to all connected clients)
|
||||
_broadcast_agent_data({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'accepted',
|
||||
@@ -631,27 +690,35 @@ def get_payloads():
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/stream/all')
|
||||
def stream_all_agents():
|
||||
def stream_all_agents():
|
||||
"""
|
||||
Combined SSE stream for data from all agents.
|
||||
|
||||
This endpoint streams push data as it arrives from agents.
|
||||
Each message is tagged with agent_id and agent_name.
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = agent_data_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.add(client_queue)
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
finally:
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.discard(client_queue)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
|
||||
@@ -35,10 +35,12 @@ from utils.database import (
|
||||
get_dsc_alert_summary,
|
||||
)
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
logger = logging.getLogger('intercept.dsc')
|
||||
|
||||
@@ -169,17 +171,34 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
'error': str(e)
|
||||
})
|
||||
finally:
|
||||
global dsc_active_device
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
decoder_process.wait()
|
||||
dsc_running = False
|
||||
# Cleanup both processes
|
||||
with app_module.dsc_lock:
|
||||
rtl_proc = app_module.dsc_rtl_process
|
||||
for proc in [rtl_proc, decoder_process]:
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
|
||||
with app_module.dsc_lock:
|
||||
app_module.dsc_process = None
|
||||
app_module.dsc_rtl_process = None
|
||||
# Release SDR device
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
dsc_active_device = None
|
||||
|
||||
|
||||
def _store_critical_alert(msg: dict) -> None:
|
||||
@@ -362,6 +381,7 @@ def start_decoding() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
# Start stderr monitor thread
|
||||
stderr_thread = threading.Thread(
|
||||
@@ -382,6 +402,7 @@ def start_decoding() -> Response:
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
register_process(decoder_process)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
@@ -408,6 +429,15 @@ def start_decoding() -> Response:
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Kill orphaned rtl_fm process
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
@@ -417,6 +447,15 @@ def start_decoding() -> Response:
|
||||
'message': f'Tool not found: {e.filename}'
|
||||
}), 400
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
@@ -479,22 +518,19 @@ def stop_decoding() -> Response:
|
||||
@dsc_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
"""SSE stream for real-time DSC messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('dsc', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.dsc_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.dsc_queue,
|
||||
channel_key='dsc',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
|
||||
@@ -4,19 +4,24 @@ from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
from collections.abc import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.gps import (
|
||||
get_gps_reader,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
GPSPosition,
|
||||
GPSSkyData,
|
||||
detect_gps_devices,
|
||||
get_current_position,
|
||||
get_gps_reader,
|
||||
is_gpsd_running,
|
||||
start_gpsd,
|
||||
start_gpsd_daemon,
|
||||
stop_gps,
|
||||
stop_gpsd_daemon,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
|
||||
@@ -29,12 +34,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
def _position_callback(position: GPSPosition) -> None:
|
||||
"""Callback to queue position updates for SSE stream."""
|
||||
try:
|
||||
_gps_queue.put_nowait(position.to_dict())
|
||||
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||
except queue.Full:
|
||||
# Discard oldest if queue is full
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
_gps_queue.put_nowait(position.to_dict())
|
||||
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def _sky_callback(sky: GPSSkyData) -> None:
|
||||
"""Callback to queue sky data updates for SSE stream."""
|
||||
try:
|
||||
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||
except queue.Full:
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
@@ -45,36 +62,44 @@ def auto_connect_gps():
|
||||
Automatically connect to gpsd if available.
|
||||
|
||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
import socket
|
||||
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None
|
||||
'position': position.to_dict() if position else None,
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
})
|
||||
|
||||
# Try to connect to gpsd on localhost:2947
|
||||
host = 'localhost'
|
||||
port = 2947
|
||||
|
||||
# First check if gpsd is reachable
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
except Exception:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'gpsd not running'
|
||||
})
|
||||
# If gpsd isn't running, try to detect a device and start it
|
||||
if not is_gpsd_running(host, port):
|
||||
devices = detect_gps_devices()
|
||||
if not devices:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'No GPS device detected'
|
||||
})
|
||||
|
||||
# Try to start gpsd with the first detected device
|
||||
device_path = devices[0]['path']
|
||||
success, msg = start_gpsd_daemon(device_path, host, port)
|
||||
if not success:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': msg,
|
||||
'devices': devices,
|
||||
})
|
||||
logger.info(f"Auto-started gpsd on {device_path}")
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
@@ -84,14 +109,17 @@ def auto_connect_gps():
|
||||
break
|
||||
|
||||
# Start the gpsd client
|
||||
success = start_gpsd(host, port, callback=_position_callback)
|
||||
success = start_gpsd(host, port,
|
||||
callback=_position_callback,
|
||||
sky_callback=_sky_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': False,
|
||||
'position': None
|
||||
'position': None,
|
||||
'sky': None,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
@@ -100,14 +128,26 @@ def auto_connect_gps():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/devices')
|
||||
def list_gps_devices():
|
||||
"""List detected GPS serial devices."""
|
||||
devices = detect_gps_devices()
|
||||
return jsonify({
|
||||
'devices': devices,
|
||||
'gpsd_running': is_gpsd_running(),
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS client."""
|
||||
"""Stop GPS client and gpsd daemon if we started it."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
reader.remove_sky_callback(_sky_callback)
|
||||
|
||||
stop_gps()
|
||||
stop_gpsd_daemon()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -122,15 +162,18 @@ def get_gps_status():
|
||||
'running': False,
|
||||
'device': None,
|
||||
'position': None,
|
||||
'sky': None,
|
||||
'error': None,
|
||||
'message': 'GPS client not started'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'device': reader.device_path,
|
||||
'position': position.to_dict() if position else None,
|
||||
'sky': sky.to_dict() if sky else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
'error': reader.error,
|
||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||
@@ -161,26 +204,43 @@ def get_position():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position updates."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
while True:
|
||||
try:
|
||||
position = _gps_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse({'type': 'position', **position})
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'sky': sky.to_dict()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'waiting',
|
||||
'message': 'Waiting for satellite data'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.meshtastic import (
|
||||
get_meshtastic_client,
|
||||
start_meshtastic,
|
||||
@@ -453,8 +453,8 @@ def get_messages():
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
"""
|
||||
SSE stream of Meshtastic messages.
|
||||
|
||||
@@ -469,25 +469,18 @@ def stream_messages():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = _mesh_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_mesh_queue,
|
||||
channel_key='meshtastic',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@@ -1051,3 +1044,19 @@ def request_store_forward():
|
||||
'status': 'error',
|
||||
'message': error or 'Failed to request S&F history'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/topology')
|
||||
def mesh_topology():
|
||||
"""Return mesh network topology graph."""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
if not client or not client.is_running:
|
||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'topology': client.get_topology(),
|
||||
})
|
||||
|
||||
@@ -9,13 +9,14 @@ import os
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'openstreetmap',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
@@ -44,6 +45,9 @@ ASSET_PATHS = {
|
||||
'static/vendor/leaflet/images/marker-shadow.png',
|
||||
'static/vendor/leaflet/images/layers.png',
|
||||
'static/vendor/leaflet/images/layers-2x.png'
|
||||
],
|
||||
'leaflet_heat': [
|
||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -22,8 +24,9 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
@@ -93,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
"""Log a message to file if logging is enabled."""
|
||||
if not app_module.logging_enabled:
|
||||
return
|
||||
@@ -101,8 +104,79 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
data = rtl_stdout.read(CHUNK)
|
||||
if not data:
|
||||
break
|
||||
|
||||
# Forward audio untouched
|
||||
try:
|
||||
multimon_stdin.write(data)
|
||||
multimon_stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
break
|
||||
|
||||
# Compute scope levels every ~100 ms
|
||||
now = time.monotonic()
|
||||
if now - last_scope >= INTERVAL:
|
||||
last_scope = now
|
||||
try:
|
||||
n_samples = len(data) // 2
|
||||
if n_samples == 0:
|
||||
continue
|
||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
try:
|
||||
multimon_stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
@@ -146,14 +220,37 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global pager_active_device
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
process.wait()
|
||||
# Signal relay thread to stop
|
||||
with app_module.process_lock:
|
||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||
if stop_relay:
|
||||
stop_relay.set()
|
||||
# Cleanup companion rtl_fm process and decoder
|
||||
with app_module.process_lock:
|
||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||
for proc in [rtl_proc, process]:
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.process_lock:
|
||||
app_module.current_process = None
|
||||
# Release SDR device
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
pager_active_device = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
@@ -281,6 +378,7 @@ def start_decoding() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
@@ -299,18 +397,30 @@ def start_decoding() -> Response:
|
||||
|
||||
multimon_process = subprocess.Popen(
|
||||
multimon_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
register_process(multimon_process)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||||
stop_relay = threading.Event()
|
||||
relay = threading.Thread(
|
||||
target=audio_relay_thread,
|
||||
args=(rtl_process.stdout, multimon_process.stdin,
|
||||
app_module.output_queue, stop_relay),
|
||||
)
|
||||
relay.daemon = True
|
||||
relay.start()
|
||||
|
||||
app_module.current_process = multimon_process
|
||||
app_module.current_process._rtl_process = rtl_process
|
||||
app_module.current_process._master_fd = master_fd
|
||||
app_module.current_process._stop_relay = stop_relay
|
||||
app_module.current_process._relay_thread = relay
|
||||
|
||||
# Start output thread with PTY master fd
|
||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||
@@ -322,12 +432,30 @@ def start_decoding() -> Response:
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Kill orphaned rtl_fm process
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
pager_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
@@ -341,6 +469,10 @@ def stop_decoding() -> Response:
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
# Signal audio relay thread to stop
|
||||
if hasattr(app_module.current_process, '_stop_relay'):
|
||||
app_module.current_process._stop_relay.set()
|
||||
|
||||
# Kill rtl_fm process first
|
||||
if hasattr(app_module.current_process, '_rtl_process'):
|
||||
try:
|
||||
@@ -421,27 +553,22 @@ def toggle_logging() -> Response:
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
import json
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Session recording API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
|
||||
from utils.recording import get_recording_manager, RECORDING_ROOT
|
||||
|
||||
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
||||
|
||||
|
||||
@recordings_bp.route('/start', methods=['POST'])
|
||||
def start_recording():
|
||||
data = request.get_json() or {}
|
||||
mode = (data.get('mode') or '').strip()
|
||||
if not mode:
|
||||
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
|
||||
|
||||
label = data.get('label')
|
||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||
|
||||
manager = get_recording_manager()
|
||||
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'file_path': str(session.file_path),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@recordings_bp.route('/stop', methods=['POST'])
|
||||
def stop_recording():
|
||||
data = request.get_json() or {}
|
||||
mode = data.get('mode')
|
||||
session_id = data.get('id')
|
||||
|
||||
manager = get_recording_manager()
|
||||
session = manager.stop_recording(mode=mode, session_id=session_id)
|
||||
if not session:
|
||||
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||
'event_count': session.event_count,
|
||||
'size_bytes': session.size_bytes,
|
||||
'file_path': str(session.file_path),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@recordings_bp.route('', methods=['GET'])
|
||||
def list_recordings():
|
||||
manager = get_recording_manager()
|
||||
limit = request.args.get('limit', default=50, type=int)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'recordings': manager.list_recordings(limit=limit),
|
||||
'active': manager.get_active(),
|
||||
})
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>', methods=['GET'])
|
||||
def get_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
return jsonify({'status': 'success', 'recording': rec})
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
||||
def download_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
|
||||
file_path = Path(rec['file_path'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
if resolved_root not in resolved_file.parents:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
mimetype='application/x-ndjson',
|
||||
as_attachment=True,
|
||||
download_name=file_path.name,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
})
|
||||
@@ -17,8 +17,9 @@ from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
|
||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||
|
||||
@@ -61,10 +62,37 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
# Ensure rtlamr process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
# Kill companion rtl_tcp process
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
try:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_tcp_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(rtl_tcp_process)
|
||||
rtl_tcp_process = None
|
||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.rtlamr_lock:
|
||||
app_module.rtlamr_process = None
|
||||
# Release SDR device
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
rtlamr_active_device = None
|
||||
|
||||
|
||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||
@@ -133,7 +161,8 @@ def start_rtlamr() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
register_process(rtl_tcp_process)
|
||||
|
||||
# Wait a moment for rtl_tcp to start
|
||||
time.sleep(3)
|
||||
|
||||
@@ -141,6 +170,10 @@ def start_rtlamr() -> Response:
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||
# Release SDR device on rtl_tcp failure
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||
|
||||
# Build rtlamr command
|
||||
@@ -174,6 +207,7 @@ def start_rtlamr() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.rtlamr_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
||||
@@ -254,22 +288,19 @@ def stop_rtlamr() -> Response:
|
||||
|
||||
@rtlamr_bp.route('/stream_rtlamr')
|
||||
def stream_rtlamr() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.rtlamr_queue,
|
||||
channel_key='rtlamr',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
|
||||
@@ -11,11 +11,18 @@ from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.database import (
|
||||
get_tracked_satellites,
|
||||
add_tracked_satellite,
|
||||
bulk_add_tracked_satellites,
|
||||
update_tracked_satellite,
|
||||
remove_tracked_satellite,
|
||||
)
|
||||
from utils.logging import satellite_logger as logger
|
||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||
|
||||
@@ -31,6 +38,43 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
||||
_tle_cache = dict(TLE_SATELLITES)
|
||||
|
||||
|
||||
def _load_db_satellites_into_cache():
|
||||
"""Load user-tracked satellites from DB into the TLE cache."""
|
||||
global _tle_cache
|
||||
try:
|
||||
db_sats = get_tracked_satellites()
|
||||
loaded = 0
|
||||
for sat in db_sats:
|
||||
if sat['tle_line1'] and sat['tle_line2']:
|
||||
# Use a cache key derived from name (sanitised)
|
||||
cache_key = sat['name'].replace(' ', '-').upper()
|
||||
if cache_key not in _tle_cache:
|
||||
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
|
||||
loaded += 1
|
||||
if loaded:
|
||||
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
||||
|
||||
|
||||
def init_tle_auto_refresh():
|
||||
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
||||
import threading
|
||||
|
||||
def _auto_refresh_tle():
|
||||
try:
|
||||
_load_db_satellites_into_cache()
|
||||
updated = refresh_tle_data()
|
||||
if updated:
|
||||
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto TLE refresh failed: {e}")
|
||||
|
||||
# Start auto-refresh in background
|
||||
threading.Timer(2.0, _auto_refresh_tle).start()
|
||||
logger.info("TLE auto-refresh scheduled")
|
||||
|
||||
|
||||
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||
"""
|
||||
Fetch real-time ISS position from external APIs.
|
||||
@@ -42,30 +86,30 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
iss_alt = 420 # Default altitude in km
|
||||
source = None
|
||||
|
||||
# Try primary API: Open Notify
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', 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'])
|
||||
source = 'open-notify'
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
iss_alt = float(data.get('altitude', 420))
|
||||
source = 'wheretheiss'
|
||||
except Exception as e:
|
||||
logger.debug(f"Open Notify API failed: {e}")
|
||||
logger.debug(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Where The ISS At
|
||||
# Try fallback API: Open Notify
|
||||
if iss_lat is None:
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
iss_alt = float(data.get('altitude', 420))
|
||||
source = 'wheretheiss'
|
||||
if data.get('message') == 'success':
|
||||
iss_lat = float(data['iss_position']['latitude'])
|
||||
iss_lon = float(data['iss_position']['longitude'])
|
||||
source = 'open-notify'
|
||||
except Exception as e:
|
||||
logger.debug(f"Where The ISS At API failed: {e}")
|
||||
logger.debug(f"Open Notify API failed: {e}")
|
||||
|
||||
if iss_lat is None:
|
||||
return None
|
||||
@@ -120,12 +164,14 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
|
||||
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
@@ -153,15 +199,11 @@ def predict_passes():
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
||||
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
@@ -172,10 +214,6 @@ def predict_passes():
|
||||
passes = []
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'NOAA-15': '#00ff00',
|
||||
'NOAA-18': '#ff6600',
|
||||
'NOAA-19': '#ff3366',
|
||||
'NOAA-20': '#00ffaa',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff'
|
||||
}
|
||||
@@ -312,10 +350,6 @@ def get_satellite_position():
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
@@ -481,7 +515,8 @@ def update_tle():
|
||||
'updated': updated
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
logger.error(f"Error updating TLE data: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'TLE update failed'})
|
||||
|
||||
|
||||
@satellite_bp.route('/celestrak/<category>')
|
||||
@@ -535,4 +570,104 @@ def fetch_celestrak(category):
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tracked Satellites CRUD
|
||||
# =============================================================================
|
||||
|
||||
@satellite_bp.route('/tracked', methods=['GET'])
|
||||
def list_tracked_satellites():
|
||||
"""Return all tracked satellites from the database."""
|
||||
enabled_only = request.args.get('enabled', '').lower() == 'true'
|
||||
sats = get_tracked_satellites(enabled_only=enabled_only)
|
||||
return jsonify({'status': 'success', 'satellites': sats})
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked', methods=['POST'])
|
||||
def add_tracked_satellites_endpoint():
|
||||
"""Add one or more tracked satellites."""
|
||||
global _tle_cache
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
# Accept a single satellite dict or a list
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
|
||||
normalized: list[dict] = []
|
||||
for sat in sat_list:
|
||||
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||
name = sat.get('name', '')
|
||||
if not norad_id or not name:
|
||||
continue
|
||||
tle1 = sat.get('tle_line1', sat.get('tle1'))
|
||||
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||
enabled = sat.get('enabled', True)
|
||||
|
||||
normalized.append({
|
||||
'norad_id': norad_id,
|
||||
'name': name,
|
||||
'tle_line1': tle1,
|
||||
'tle_line2': tle2,
|
||||
'enabled': bool(enabled),
|
||||
'builtin': False,
|
||||
})
|
||||
|
||||
# Also inject into TLE cache if we have TLE data
|
||||
if tle1 and tle2:
|
||||
cache_key = name.replace(' ', '-').upper()
|
||||
_tle_cache[cache_key] = (name, tle1, tle2)
|
||||
|
||||
# Single inserts preserve previous behavior; list inserts use DB-level bulk path.
|
||||
if len(normalized) == 1:
|
||||
sat = normalized[0]
|
||||
added = 1 if add_tracked_satellite(
|
||||
sat['norad_id'],
|
||||
sat['name'],
|
||||
sat.get('tle_line1'),
|
||||
sat.get('tle_line2'),
|
||||
sat.get('enabled', True),
|
||||
sat.get('builtin', False),
|
||||
) else 0
|
||||
else:
|
||||
added = bulk_add_tracked_satellites(normalized)
|
||||
|
||||
response_payload = {
|
||||
'status': 'success',
|
||||
'added': added,
|
||||
'processed': len(normalized),
|
||||
}
|
||||
|
||||
# Returning all tracked satellites for very large imports can stall the UI.
|
||||
include_satellites = request.args.get('include_satellites', '').lower() == 'true'
|
||||
if include_satellites or len(normalized) <= 32:
|
||||
response_payload['satellites'] = get_tracked_satellites()
|
||||
|
||||
return jsonify(response_payload)
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
|
||||
def update_tracked_satellite_endpoint(norad_id):
|
||||
"""Update the enabled state of a tracked satellite."""
|
||||
data = request.json or {}
|
||||
enabled = data.get('enabled')
|
||||
if enabled is None:
|
||||
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
|
||||
|
||||
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
||||
if ok:
|
||||
return jsonify({'status': 'success'})
|
||||
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
|
||||
def delete_tracked_satellite_endpoint(norad_id):
|
||||
"""Remove a tracked satellite by NORAD ID."""
|
||||
ok, msg = remove_tracked_satellite(str(norad_id))
|
||||
if ok:
|
||||
return jsonify({'status': 'success', 'message': msg})
|
||||
status_code = 403 if 'builtin' in msg.lower() else 404
|
||||
return jsonify({'status': 'error', 'message': msg}), status_code
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
@@ -18,8 +19,9 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
@@ -27,8 +29,42 @@ sensor_bp = Blueprint('sensor', __name__)
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
|
||||
|
||||
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
||||
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
||||
points = max(32, min(points, 512))
|
||||
|
||||
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
||||
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
||||
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
||||
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
||||
|
||||
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
||||
cycles = 3.0 + (snr_norm * 8.0)
|
||||
harmonic = 0.25 + (0.35 * snr_norm)
|
||||
hiss = 0.08 + (0.18 * noise_norm)
|
||||
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
||||
|
||||
waveform: list[int] = []
|
||||
for i in range(points):
|
||||
t = i / (points - 1)
|
||||
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
||||
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
||||
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
||||
|
||||
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
||||
sample /= (1.0 + harmonic + hiss)
|
||||
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
|
||||
return waveform
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
@@ -44,6 +80,40 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||
snr_value = float(snr) if snr is not None else 0.0
|
||||
noise_value = float(noise) if noise is not None else 0.0
|
||||
app_module.sensor_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rssi': rssi_value,
|
||||
'snr': snr_value,
|
||||
'noise': noise_value,
|
||||
'waveform': _build_scope_waveform(
|
||||
rssi=rssi_value,
|
||||
snr=snr_value,
|
||||
noise=noise_value,
|
||||
),
|
||||
})
|
||||
except (TypeError, ValueError, queue.Full):
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -59,10 +129,32 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
global sensor_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
@@ -143,12 +235,17 @@ def start_sensor() -> Response:
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
@@ -156,10 +253,16 @@ def start_sensor() -> Response:
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err:
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
@@ -208,25 +311,31 @@ def stop_sensor() -> Response:
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.sensor_queue,
|
||||
channel_key='sensor',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
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,300 @@
|
||||
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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."""
|
||||
data = {
|
||||
'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(),
|
||||
'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'})
|
||||
@@ -13,36 +13,53 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_sstv_decoder,
|
||||
is_sstv_available,
|
||||
ISS_SSTV_FREQ,
|
||||
DecodeProgress,
|
||||
DopplerInfo,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.sstv')
|
||||
|
||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||
|
||||
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
||||
# can type nearby values and still land on the canonical center frequency.
|
||||
ISS_SSTV_MODULATION = 'fm'
|
||||
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
||||
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track which device is being used
|
||||
sstv_active_device: int | None = None
|
||||
|
||||
def _progress_callback(progress: DecodeProgress) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress/scope updates for SSE stream."""
|
||||
try:
|
||||
_sstv_queue.put_nowait(progress.to_dict())
|
||||
_sstv_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_sstv_queue.get_nowait()
|
||||
_sstv_queue.put_nowait(progress.to_dict())
|
||||
_sstv_queue.put_nowait(data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
||||
"""Snap near-match user input to a supported ISS SSTV center frequency."""
|
||||
for supported in ISS_SSTV_FREQUENCIES:
|
||||
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
|
||||
return supported
|
||||
return None
|
||||
|
||||
|
||||
@sstv_bp.route('/status')
|
||||
def get_status():
|
||||
"""
|
||||
@@ -59,6 +76,7 @@ def get_status():
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'iss_frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'image_count': len(decoder.get_images()),
|
||||
'doppler_enabled': decoder.doppler_enabled,
|
||||
}
|
||||
@@ -79,6 +97,7 @@ def start_decoder():
|
||||
JSON body (optional):
|
||||
{
|
||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||
"modulation": "fm", // ISS mode is FM-only
|
||||
"device": 0, // RTL-SDR device index
|
||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||
@@ -94,7 +113,7 @@ def start_decoder():
|
||||
if not is_sstv_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
|
||||
}), 400
|
||||
|
||||
decoder = get_sstv_decoder()
|
||||
@@ -103,6 +122,7 @@ def start_decoder():
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
})
|
||||
|
||||
@@ -116,18 +136,29 @@ def start_decoder():
|
||||
# Get parameters
|
||||
data = request.get_json(silent=True) or {}
|
||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||
device_index = data.get('device', 0)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
# Validate modulation (ISS mode is FM-only)
|
||||
if modulation != ISS_SSTV_MODULATION:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
||||
}), 400
|
||||
|
||||
# Validate frequency
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (100 <= frequency <= 500): # VHF range
|
||||
normalized_frequency = _normalize_iss_frequency(frequency)
|
||||
if normalized_frequency is None:
|
||||
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 100-500 MHz'
|
||||
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||
}), 400
|
||||
frequency = normalized_frequency
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -158,19 +189,34 @@ def start_decoder():
|
||||
latitude = None
|
||||
longitude = None
|
||||
|
||||
# Claim SDR device
|
||||
global sstv_active_device
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
success = decoder.start(
|
||||
frequency=frequency,
|
||||
device_index=device_index,
|
||||
latitude=latitude,
|
||||
longitude=longitude
|
||||
longitude=longitude,
|
||||
modulation=ISS_SSTV_MODULATION,
|
||||
)
|
||||
|
||||
if success:
|
||||
sstv_active_device = device_int
|
||||
|
||||
result = {
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'device': device_index,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
}
|
||||
@@ -181,6 +227,8 @@ def start_decoder():
|
||||
|
||||
return jsonify(result)
|
||||
else:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder'
|
||||
@@ -195,8 +243,15 @@ def stop_decoder():
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
global sstv_active_device
|
||||
decoder = get_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
# Release device from registry
|
||||
if sstv_active_device is not None:
|
||||
app_module.release_sdr_device(sstv_active_device)
|
||||
sstv_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -287,8 +342,75 @@ def get_image(filename: str):
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
|
||||
@sstv_bp.route('/stream')
|
||||
def stream_progress():
|
||||
@sstv_bp.route('/images/<filename>/download')
|
||||
def download_image(filename: str):
|
||||
"""
|
||||
Download a decoded SSTV image file.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
Image file as attachment or 404.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""
|
||||
Delete a decoded SSTV image.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@sstv_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""
|
||||
Delete all decoded SSTV images.
|
||||
|
||||
Returns:
|
||||
JSON with count of deleted images.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@sstv_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""
|
||||
SSE stream of SSTV decode progress.
|
||||
|
||||
@@ -300,25 +422,22 @@ def stream_progress():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
progress = _sstv_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(progress)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_queue,
|
||||
channel_key='sstv',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@@ -467,7 +586,32 @@ def iss_position():
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
|
||||
# Try primary API: Open Notify
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
|
||||
# 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"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:
|
||||
@@ -493,31 +637,6 @@ def iss_position():
|
||||
except Exception as e:
|
||||
logger.warning(f"Open Notify API failed: {e}")
|
||||
|
||||
# Try fallback API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
|
||||
# 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"Where The ISS At API failed: {e}")
|
||||
|
||||
# Both APIs failed
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""General SSTV (Slow-Scan Television) decoder routes.
|
||||
|
||||
Provides endpoints for decoding terrestrial SSTV images on common HF/VHF/UHF
|
||||
frequencies used by amateur radio operators worldwide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_general_sstv_decoder,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.sstv_general')
|
||||
|
||||
sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general')
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track which device is being used
|
||||
_sstv_general_active_device: int | None = None
|
||||
|
||||
# Predefined 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.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'},
|
||||
{'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'},
|
||||
{'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'},
|
||||
{'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'},
|
||||
{'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'},
|
||||
{'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'},
|
||||
{'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', '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': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||
]
|
||||
|
||||
# Build a lookup for auto-detecting modulation from frequency
|
||||
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress/scope updates for SSE stream."""
|
||||
try:
|
||||
_sstv_general_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_sstv_general_queue.get_nowait()
|
||||
_sstv_general_queue.put_nowait(data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@sstv_general_bp.route('/frequencies')
|
||||
def get_frequencies():
|
||||
"""Return the predefined SSTV frequency table."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'frequencies': SSTV_FREQUENCIES,
|
||||
})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get general SSTV decoder status."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
return jsonify({
|
||||
'available': decoder.decoder_available is not None,
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'image_count': len(decoder.get_images()),
|
||||
})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/start', methods=['POST'])
|
||||
def start_decoder():
|
||||
"""
|
||||
Start general SSTV decoder.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"frequency": 14.230, // Frequency in MHz (required)
|
||||
"modulation": "usb", // fm, usb, or lsb (auto-detected from frequency table if omitted)
|
||||
"device": 0 // RTL-SDR device index
|
||||
}
|
||||
"""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
if decoder.decoder_available is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
|
||||
}), 400
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
})
|
||||
|
||||
# Clear queue
|
||||
while not _sstv_general_queue.empty():
|
||||
try:
|
||||
_sstv_general_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
frequency = data.get('frequency')
|
||||
modulation = data.get('modulation')
|
||||
device_index = data.get('device', 0)
|
||||
|
||||
# Validate frequency
|
||||
if frequency is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency is required',
|
||||
}), 400
|
||||
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (1 <= frequency <= 500):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
|
||||
}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid frequency',
|
||||
}), 400
|
||||
|
||||
# Auto-detect modulation from frequency table if not specified
|
||||
if not modulation:
|
||||
modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb')
|
||||
|
||||
# Validate modulation
|
||||
if modulation not in ('fm', 'usb', 'lsb'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Modulation must be fm, usb, or lsb',
|
||||
}), 400
|
||||
|
||||
# Claim SDR device
|
||||
global _sstv_general_active_device
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv_general')
|
||||
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=frequency,
|
||||
device_index=device_index,
|
||||
modulation=modulation,
|
||||
)
|
||||
|
||||
if success:
|
||||
_sstv_general_active_device = device_int
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'device': device_index,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
}), 500
|
||||
|
||||
|
||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop general SSTV decoder."""
|
||||
global _sstv_general_active_device
|
||||
decoder = get_general_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if _sstv_general_active_device is not None:
|
||||
app_module.release_sdr_device(_sstv_general_active_device)
|
||||
_sstv_general_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded SSTV images."""
|
||||
decoder = get_general_sstv_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),
|
||||
})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Get a decoded SSTV image file."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images/<filename>/download')
|
||||
def download_image(filename: str):
|
||||
"""Download a decoded SSTV image file."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded SSTV image."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""Delete all decoded SSTV images."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of SSTV decode progress."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv_general', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_general_queue,
|
||||
channel_key='sstv_general',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@sstv_general_bp.route('/decode-file', methods=['POST'])
|
||||
def decode_file():
|
||||
"""Decode SSTV from an uploaded audio file."""
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No audio file provided',
|
||||
}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
|
||||
if not audio_file.filename:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No file selected',
|
||||
}), 400
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
audio_file.save(tmp.name)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
decoder = get_general_sstv_decoder()
|
||||
images = decoder.decode_file(tmp_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding file: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}), 500
|
||||
|
||||
finally:
|
||||
try:
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,429 @@
|
||||
"""SubGHz transceiver routes.
|
||||
|
||||
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
|
||||
signal replay/transmit, and wideband spectrum analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
SUBGHZ_LNA_GAIN_MAX,
|
||||
SUBGHZ_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_MAX_DURATION,
|
||||
SUBGHZ_SAMPLE_RATES,
|
||||
SUBGHZ_PRESETS,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.subghz')
|
||||
|
||||
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
||||
|
||||
# SSE queue for streaming events to frontend
|
||||
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
process_event('subghz', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_subghz_queue.get_nowait()
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
|
||||
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
|
||||
raw = data.get(key)
|
||||
if raw is None:
|
||||
return None, f'{key} is required'
|
||||
try:
|
||||
freq_hz = int(raw)
|
||||
freq_mhz = freq_hz / 1_000_000
|
||||
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
|
||||
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
|
||||
return freq_hz, None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_serial(data: dict) -> str | None:
|
||||
"""Extract and validate optional HackRF device serial."""
|
||||
serial = data.get('device_serial', '')
|
||||
if not serial or not isinstance(serial, str):
|
||||
return None
|
||||
# HackRF serials are hex strings
|
||||
serial = serial.strip()
|
||||
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
|
||||
return serial
|
||||
return None
|
||||
|
||||
|
||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||
"""Validate integer parameter with bounds clamping."""
|
||||
try:
|
||||
val = int(data.get(key, default))
|
||||
return max(min_val, min(max_val, val))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||
profile = data.get('decode_profile', default)
|
||||
if not isinstance(profile, str):
|
||||
return default
|
||||
profile = profile.strip().lower()
|
||||
if profile in {'weather', 'all'}:
|
||||
return profile
|
||||
return default
|
||||
|
||||
|
||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == '':
|
||||
return None, None
|
||||
try:
|
||||
return float(raw), None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||
raw = data.get(key, default)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# STATUS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/status')
|
||||
def get_status():
|
||||
manager = get_subghz_manager()
|
||||
return jsonify(manager.get_status())
|
||||
|
||||
|
||||
@subghz_bp.route('/presets')
|
||||
def get_presets():
|
||||
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECEIVE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/receive/start', methods=['POST'])
|
||||
def start_receive():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_receive(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
trigger_enabled=trigger_enabled,
|
||||
trigger_pre_ms=trigger_pre_ms,
|
||||
trigger_post_ms=trigger_post_ms,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/receive/stop', methods=['POST'])
|
||||
def stop_receive():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_receive()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DECODE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/decode/start', methods=['POST'])
|
||||
def start_decode():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
decode_profile = _validate_decode_profile(data)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_decode(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
decode_profile=decode_profile,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/decode/stop', methods=['POST'])
|
||||
def stop_decode():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_decode()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TRANSMIT
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/transmit', methods=['POST'])
|
||||
def start_transmit():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
capture_id = data.get('capture_id')
|
||||
if not capture_id or not isinstance(capture_id, str):
|
||||
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
|
||||
|
||||
# Sanitize capture_id
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
||||
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.transmit(
|
||||
capture_id=capture_id,
|
||||
tx_gain=tx_gain,
|
||||
max_duration=max_duration,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/transmit/stop', methods=['POST'])
|
||||
def stop_transmit():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_transmit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SWEEP
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/sweep/start', methods=['POST'])
|
||||
def start_sweep():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
freq_start = float(data.get('freq_start_mhz', 300))
|
||||
freq_end = float(data.get('freq_end_mhz', 928))
|
||||
if freq_start >= freq_end:
|
||||
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
|
||||
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
||||
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
|
||||
|
||||
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_sweep(
|
||||
freq_start_mhz=freq_start,
|
||||
freq_end_mhz=freq_end,
|
||||
bin_width=bin_width,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/sweep/stop', methods=['POST'])
|
||||
def stop_sweep():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_sweep()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CAPTURES LIBRARY
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/captures')
|
||||
def list_captures():
|
||||
manager = get_subghz_manager()
|
||||
captures = manager.list_captures()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'captures': [c.to_dict() for c in captures],
|
||||
'count': len(captures),
|
||||
})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>')
|
||||
def get_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
capture = manager.get_capture(capture_id)
|
||||
if not capture:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/download')
|
||||
def download_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
path = manager.get_capture_path(capture_id)
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
return send_file(
|
||||
path,
|
||||
mimetype='application/octet-stream',
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||
def trim_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
|
||||
label = data.get('label', '')
|
||||
if label is None:
|
||||
label = ''
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
result = manager.trim_capture(
|
||||
capture_id=capture_id,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if result.get('status') == 'ok':
|
||||
return jsonify(result), 200
|
||||
message = str(result.get('message') or 'Trim failed')
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||
def delete_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.delete_capture(capture_id):
|
||||
return jsonify({'status': 'deleted', 'id': capture_id})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
||||
def update_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
label = data.get('label', '')
|
||||
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.update_capture_label(capture_id, label):
|
||||
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSE STREAM
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/stream')
|
||||
def stream():
|
||||
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.updater import (
|
||||
check_for_updates,
|
||||
get_update_status,
|
||||
dismiss_update,
|
||||
get_update_status,
|
||||
perform_update,
|
||||
restart_application,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.routes.updater')
|
||||
@@ -137,3 +138,42 @@ def dismiss_notification() -> Response:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@updater_bp.route('/restart', methods=['POST'])
|
||||
def restart_app() -> Response:
|
||||
"""
|
||||
Restart the application.
|
||||
|
||||
This endpoint triggers a graceful restart of the application:
|
||||
1. Stops all running decoder processes
|
||||
2. Cleans up global state
|
||||
3. Replaces the current process with a fresh instance
|
||||
|
||||
The response may not be received by the client since the process
|
||||
is replaced immediately. Clients should poll /health until the
|
||||
server responds again.
|
||||
|
||||
Returns:
|
||||
JSON with restart status (may not be delivered)
|
||||
"""
|
||||
import threading
|
||||
|
||||
logger.info("Restart requested via API")
|
||||
|
||||
# Send response before restarting
|
||||
# Use a short delay to allow the response to be sent
|
||||
def delayed_restart():
|
||||
import time
|
||||
time.sleep(0.5) # Allow response to be sent
|
||||
restart_application()
|
||||
|
||||
# Start restart in a background thread so we can return a response
|
||||
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
|
||||
restart_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Application is restarting. Please wait...',
|
||||
'action': 'restart'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""VDL2 aircraft datalink routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
||||
|
||||
# Default VDL2 frequencies (MHz) - common worldwide
|
||||
DEFAULT_VDL2_FREQUENCIES = [
|
||||
'136975000', # Primary worldwide
|
||||
'136725000', # Europe
|
||||
'136775000', # Europe
|
||||
'136800000', # Multi-region
|
||||
'136875000', # Multi-region
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
vdl2_active_device: int | None = None
|
||||
|
||||
|
||||
def find_dumpvdl2():
|
||||
"""Find dumpvdl2 binary."""
|
||||
return shutil.which('dumpvdl2')
|
||||
|
||||
|
||||
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||
"""Stream dumpvdl2 JSON output to queue."""
|
||||
global vdl2_message_count, vdl2_last_message_time
|
||||
|
||||
try:
|
||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||
sentinel = '' if is_text_mode else b''
|
||||
for line in iter(process.stdout.readline, sentinel):
|
||||
if is_text_mode:
|
||||
line = line.strip()
|
||||
else:
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
|
||||
# Add our metadata
|
||||
data['type'] = 'vdl2'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Update stats
|
||||
vdl2_message_count += 1
|
||||
vdl2_last_message_time = time.time()
|
||||
|
||||
app_module.vdl2_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_vdl2_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON - could be status message
|
||||
if line:
|
||||
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VDL2 stream error: {e}")
|
||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global vdl2_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.vdl2_lock:
|
||||
app_module.vdl2_process = None
|
||||
# Release SDR device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
|
||||
|
||||
@vdl2_bp.route('/tools')
|
||||
def check_vdl2_tools() -> Response:
|
||||
"""Check for VDL2 decoding tools."""
|
||||
has_dumpvdl2 = find_dumpvdl2() is not None
|
||||
|
||||
return jsonify({
|
||||
'dumpvdl2': has_dumpvdl2,
|
||||
'ready': has_dumpvdl2
|
||||
})
|
||||
|
||||
|
||||
@vdl2_bp.route('/status')
|
||||
def vdl2_status() -> Response:
|
||||
"""Get VDL2 decoder status."""
|
||||
running = False
|
||||
if app_module.vdl2_process:
|
||||
running = app_module.vdl2_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'message_count': vdl2_message_count,
|
||||
'last_message_time': vdl2_last_message_time,
|
||||
'queue_size': app_module.vdl2_queue.qsize()
|
||||
})
|
||||
|
||||
|
||||
@vdl2_bp.route('/start', methods=['POST'])
|
||||
def start_vdl2() -> Response:
|
||||
"""Start VDL2 decoder."""
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for dumpvdl2
|
||||
dumpvdl2_path = find_dumpvdl2()
|
||||
if not dumpvdl2_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
vdl2_active_device = device_int
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
# dumpvdl2 expects frequencies in Hz (integers)
|
||||
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
|
||||
if isinstance(frequencies, str):
|
||||
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||
|
||||
# Clear queue
|
||||
while not app_module.vdl2_queue.empty():
|
||||
try:
|
||||
app_module.vdl2_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Reset stats
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build dumpvdl2 command
|
||||
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
||||
cmd = [dumpvdl2_path]
|
||||
cmd.extend(['--output', 'decoded:json:file:path=-'])
|
||||
|
||||
if is_soapy:
|
||||
# SoapySDR device
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
device_str = builder._build_device_string(sdr_device)
|
||||
cmd.extend(['--soapysdr', device_str])
|
||||
else:
|
||||
cmd.extend(['--rtlsdr', str(device)])
|
||||
|
||||
# Add gain
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['--gain', str(gain)])
|
||||
|
||||
# Add PPM correction if specified
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['--correction', str(ppm)])
|
||||
|
||||
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
|
||||
cmd.extend(frequencies)
|
||||
|
||||
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
is_text_mode = False
|
||||
|
||||
# On macOS, use pty to avoid stdout buffering issues
|
||||
if platform.system() == 'Darwin':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# Wrap master_fd as a text file for line-buffered reading
|
||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||
is_text_mode = True
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait briefly to check if process started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = 'dumpvdl2 failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
app_module.vdl2_process = process
|
||||
register_process(process)
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_vdl2_output,
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequencies': frequencies,
|
||||
'device': device,
|
||||
'gain': gain
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@vdl2_bp.route('/stop', methods=['POST'])
|
||||
def stop_vdl2() -> Response:
|
||||
"""Stop VDL2 decoder."""
|
||||
global vdl2_active_device
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if not app_module.vdl2_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder not running'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
app_module.vdl2_process.terminate()
|
||||
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.vdl2_process.kill()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping VDL2: {e}")
|
||||
|
||||
app_module.vdl2_process = None
|
||||
|
||||
# Release device from registry
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@vdl2_bp.route('/stream')
|
||||
def stream_vdl2() -> Response:
|
||||
"""SSE stream for VDL2 messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('vdl2', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.vdl2_queue,
|
||||
channel_key='vdl2',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@vdl2_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get default VDL2 frequencies."""
|
||||
return jsonify({
|
||||
'default': DEFAULT_VDL2_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
||||
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
||||
'asia_pacific': ['136975000', '136900000'],
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,752 @@
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from flask import Flask
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.waterfall_ws')
|
||||
|
||||
AUDIO_SAMPLE_RATE = 48000
|
||||
_shared_state_lock = threading.Lock()
|
||||
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=80)
|
||||
_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,
|
||||
}
|
||||
|
||||
# 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 _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,
|
||||
) -> None:
|
||||
with _shared_state_lock:
|
||||
_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
|
||||
if not running:
|
||||
_clear_shared_audio_queue()
|
||||
|
||||
|
||||
def _set_shared_monitor(
|
||||
*,
|
||||
enabled: bool,
|
||||
frequency_mhz: float | None = None,
|
||||
modulation: str | None = None,
|
||||
squelch: int | None = None,
|
||||
) -> None:
|
||||
was_enabled = 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:
|
||||
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||
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:
|
||||
_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,
|
||||
) -> bytes | None:
|
||||
if samples.size < 32 or sample_rate <= 0:
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
n = np.arange(samples.size, dtype=np.float32)
|
||||
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n)
|
||||
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
|
||||
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||
fs1 = fs / pre_decim
|
||||
if shifted.size < 16:
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
"""Convert client sdr_type string to SDRType enum."""
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'lime_sdr': SDRType.LIME_SDR,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
}
|
||||
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
||||
|
||||
|
||||
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
||||
"""Build a minimal SDRDevice for command building."""
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
return SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=device_index,
|
||||
name=f'{sdr_type.value}-{device_index}',
|
||||
serial='N/A',
|
||||
driver=sdr_type.value,
|
||||
capabilities=caps,
|
||||
)
|
||||
|
||||
|
||||
def init_waterfall_websocket(app: Flask):
|
||||
"""Initialize WebSocket waterfall streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/waterfall')
|
||||
def waterfall_stream(ws):
|
||||
"""WebSocket endpoint for real-time waterfall streaming."""
|
||||
logger.info("WebSocket waterfall client connected")
|
||||
|
||||
# Import app module for device claiming
|
||||
import app as app_module
|
||||
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
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()
|
||||
send_queue = queue.Queue(maxsize=120)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Drain send queue first (non-blocking)
|
||||
while True:
|
||||
try:
|
||||
outgoing = send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
try:
|
||||
ws.send(outgoing)
|
||||
except Exception:
|
||||
stop_event.set()
|
||||
break
|
||||
|
||||
try:
|
||||
msg = ws.receive(timeout=0.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:
|
||||
# simple-websocket returns None on timeout AND on
|
||||
# close; check ws.connected to tell them apart.
|
||||
if not ws.connected:
|
||||
break
|
||||
if stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
stop_event.clear()
|
||||
# Flush stale frames from previous capture
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
# Allow USB device to be released by the kernel
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
try:
|
||||
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||
span_mhz = _parse_span_mhz(data)
|
||||
gain_raw = data.get('gain')
|
||||
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||
gain = None
|
||||
else:
|
||||
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 and normalize runtime settings
|
||||
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 choose a valid sample rate
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
requested_span_hz = max(1000, int(span_mhz * 1e6))
|
||||
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
|
||||
|
||||
# Compute effective frequency range
|
||||
effective_span_mhz = sample_rate / 1e6
|
||||
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||
|
||||
# Claim the device
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=center_freq_mhz,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, "
|
||||
f"span={effective_span_mhz:.1f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start I/Q capture: {e}")
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
capture_center_mhz = center_freq_mhz
|
||||
capture_start_freq = start_freq
|
||||
capture_end_freq = end_freq
|
||||
capture_span_mhz = effective_span_mhz
|
||||
|
||||
_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=False,
|
||||
frequency_mhz=center_freq_mhz,
|
||||
modulation='wfm',
|
||||
squelch=0,
|
||||
)
|
||||
|
||||
# Send started confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'center_mhz': center_freq_mhz,
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
'effective_span_mhz': effective_span_mhz,
|
||||
'db_min': db_min,
|
||||
'db_max': db_max,
|
||||
'vfo_freq_mhz': center_freq_mhz,
|
||||
}))
|
||||
|
||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt,
|
||||
_fft_size, _avg_count, _fps, _sample_rate,
|
||||
_start_freq, _end_freq, _center_mhz,
|
||||
_db_min=None, _db_max=None,
|
||||
):
|
||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||
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
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q bytes
|
||||
raw = b''
|
||||
remaining = bytes_per_frame
|
||||
while remaining > 0 and not stop_evt.is_set():
|
||||
chunk = proc.stdout.read(min(remaining, 65536))
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
if len(raw) < _fft_size * 2:
|
||||
break
|
||||
|
||||
# Process FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
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,
|
||||
db_min=_db_min,
|
||||
db_max=_db_max,
|
||||
)
|
||||
frame = build_binary_frame(
|
||||
_start_freq, _end_freq, quantized,
|
||||
)
|
||||
|
||||
# Drop frame if main loop cannot keep up.
|
||||
with suppress(queue.Full):
|
||||
_send_q.put_nowait(frame)
|
||||
|
||||
monitor_cfg = _snapshot_monitor_config()
|
||||
if monitor_cfg:
|
||||
audio_chunk = _demodulate_monitor_audio(
|
||||
samples=samples,
|
||||
sample_rate=_sample_rate,
|
||||
center_mhz=monitor_cfg.get('center_mhz', _center_mhz),
|
||||
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz),
|
||||
modulation=monitor_cfg.get('modulation', 'wfm'),
|
||||
squelch=int(monitor_cfg.get('squelch', 0)),
|
||||
)
|
||||
if audio_chunk:
|
||||
_push_shared_audio_chunk(audio_chunk)
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
stop_evt.wait(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event,
|
||||
fft_size, avg_count, fps, sample_rate,
|
||||
start_freq, end_freq, center_freq_mhz,
|
||||
db_min, db_max,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
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':
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket waterfall closed: {e}")
|
||||
finally:
|
||||
# Cleanup
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
_set_shared_capture_state(running=False)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream (which browsers see as
|
||||
# "Invalid frame header").
|
||||
with suppress(Exception):
|
||||
ws.close()
|
||||
with suppress(Exception):
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
with suppress(Exception):
|
||||
ws.sock.close()
|
||||
logger.info("WebSocket waterfall client disconnected")
|
||||
@@ -0,0 +1,633 @@
|
||||
"""Weather Satellite decoder routes.
|
||||
|
||||
Provides endpoints for capturing and decoding weather satellite images
|
||||
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
|
||||
from utils.weather_sat import (
|
||||
get_weather_sat_decoder,
|
||||
is_weather_sat_available,
|
||||
CaptureProgress,
|
||||
WEATHER_SATELLITES,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
|
||||
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
|
||||
def _progress_callback(progress: CaptureProgress) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
try:
|
||||
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||
except queue.Full:
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@weather_sat_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get weather satellite decoder status.
|
||||
|
||||
Returns:
|
||||
JSON with decoder availability and current status.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
return jsonify(decoder.get_status())
|
||||
|
||||
|
||||
@weather_sat_bp.route('/satellites')
|
||||
def list_satellites():
|
||||
"""Get list of supported weather satellites with frequencies.
|
||||
|
||||
Returns:
|
||||
JSON with satellite definitions.
|
||||
"""
|
||||
satellites = []
|
||||
for key, info in WEATHER_SATELLITES.items():
|
||||
satellites.append({
|
||||
'key': key,
|
||||
'name': info['name'],
|
||||
'frequency': info['frequency'],
|
||||
'mode': info['mode'],
|
||||
'description': info['description'],
|
||||
'active': info['active'],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'satellites': satellites,
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/start', methods=['POST'])
|
||||
def start_capture():
|
||||
"""Start weather satellite capture and decode.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // Required: satellite key
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_weather_sat_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||
}), 400
|
||||
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'satellite': decoder.current_satellite,
|
||||
'frequency': decoder.current_frequency,
|
||||
})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate satellite
|
||||
satellite = data.get('satellite')
|
||||
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||
}), 400
|
||||
|
||||
# Validate device index and gain
|
||||
try:
|
||||
device_index = validate_device_index(data.get('device', 0))
|
||||
gain = validate_gain(data.get('gain', 40.0))
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid parameter in start_capture: %s', e)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Claim SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Clear queue
|
||||
while not _weather_sat_queue.empty():
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Set callback and on-complete handler for SDR release
|
||||
decoder.set_callback(_progress_callback)
|
||||
|
||||
def _release_device():
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
decoder.set_on_complete(_release_device)
|
||||
|
||||
success = decoder.start(
|
||||
satellite=satellite,
|
||||
device_index=device_index,
|
||||
gain=gain,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
if success:
|
||||
sat_info = WEATHER_SATELLITES[satellite]
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'satellite': satellite,
|
||||
'frequency': sat_info['frequency'],
|
||||
'mode': sat_info['mode'],
|
||||
'device': device_index,
|
||||
})
|
||||
else:
|
||||
# Release device on failure
|
||||
_release_device()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start capture'
|
||||
}), 500
|
||||
|
||||
|
||||
@weather_sat_bp.route('/test-decode', methods=['POST'])
|
||||
def test_decode():
|
||||
"""Start weather satellite decode from a pre-recorded file.
|
||||
|
||||
No SDR hardware is required — decodes an IQ baseband or WAV file
|
||||
using SatDump offline mode.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // Required: satellite key
|
||||
"input_file": "/path/to/file", // Required: server-side file path
|
||||
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_weather_sat_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||
}), 400
|
||||
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'satellite': decoder.current_satellite,
|
||||
'frequency': decoder.current_frequency,
|
||||
})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate satellite
|
||||
satellite = data.get('satellite')
|
||||
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||
}), 400
|
||||
|
||||
# Validate input file
|
||||
input_file = data.get('input_file')
|
||||
if not input_file:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file is required'
|
||||
}), 400
|
||||
|
||||
from pathlib import Path
|
||||
input_path = Path(input_file)
|
||||
|
||||
# Security: restrict to data directory (anchored to app root, not CWD)
|
||||
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
if not resolved.is_relative_to(allowed_base):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file must be under the data/ directory'
|
||||
}), 403
|
||||
except (OSError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid file path'
|
||||
}), 400
|
||||
|
||||
if not input_path.is_file():
|
||||
logger.warning("Test-decode file not found")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'File not found'
|
||||
}), 404
|
||||
|
||||
# Validate sample rate
|
||||
sample_rate = data.get('sample_rate', 1000000)
|
||||
try:
|
||||
sample_rate = int(sample_rate)
|
||||
if sample_rate < 1000 or sample_rate > 20000000:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid sample_rate (1000-20000000)'
|
||||
}), 400
|
||||
|
||||
# Clear queue
|
||||
while not _weather_sat_queue.empty():
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Set callback — no on_complete needed (no SDR to release)
|
||||
decoder.set_callback(_progress_callback)
|
||||
decoder.set_on_complete(None)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
satellite=satellite,
|
||||
input_file=input_file,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
if success:
|
||||
sat_info = WEATHER_SATELLITES[satellite]
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'satellite': satellite,
|
||||
'frequency': sat_info['frequency'],
|
||||
'mode': sat_info['mode'],
|
||||
'source': 'file',
|
||||
'input_file': str(input_file),
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start file decode'
|
||||
}), 500
|
||||
|
||||
|
||||
@weather_sat_bp.route('/stop', methods=['POST'])
|
||||
def stop_capture():
|
||||
"""Stop weather satellite capture.
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
device_index = decoder.device_index
|
||||
|
||||
decoder.stop()
|
||||
|
||||
# Release SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded weather satellite images.
|
||||
|
||||
Query parameters:
|
||||
limit: Maximum number of images (default: all)
|
||||
satellite: Filter by satellite key (optional)
|
||||
|
||||
Returns:
|
||||
JSON with list of decoded images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
# Filter by satellite if specified
|
||||
satellite_filter = request.args.get('satellite')
|
||||
if satellite_filter:
|
||||
images = [img for img in images if img.satellite == satellite_filter]
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Serve a decoded weather satellite image file.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
Image file or 404.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
# Security: only allow safe filenames
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded image.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'deleted', 'filename': filename})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""Delete all decoded weather satellite images.
|
||||
|
||||
Returns:
|
||||
JSON with count of deleted images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of capture/decode progress.
|
||||
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@weather_sat_bp.route('/passes')
|
||||
def get_passes():
|
||||
"""Get upcoming weather satellite passes for observer location.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (required)
|
||||
longitude: Observer longitude (required)
|
||||
hours: Hours to predict ahead (default: 24, max: 72)
|
||||
min_elevation: Minimum elevation in degrees (default: 15)
|
||||
trajectory: Include az/el trajectory points (default: false)
|
||||
ground_track: Include lat/lon ground track points (default: false)
|
||||
|
||||
Returns:
|
||||
JSON with upcoming passes for all weather satellites.
|
||||
"""
|
||||
include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
|
||||
include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
|
||||
|
||||
raw_lat = request.args.get('latitude')
|
||||
raw_lon = request.args.get('longitude')
|
||||
|
||||
if raw_lat is None or raw_lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
lat = validate_latitude(raw_lat)
|
||||
lon = validate_longitude(raw_lon)
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid coordinates in get_passes: %s', e)
|
||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||
|
||||
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
||||
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
||||
|
||||
try:
|
||||
from utils.weather_sat_predict import predict_passes
|
||||
|
||||
all_passes = predict_passes(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
hours=hours,
|
||||
min_elevation=min_elevation,
|
||||
include_trajectory=include_trajectory,
|
||||
include_ground_track=include_ground_track,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'passes': all_passes,
|
||||
'count': len(all_passes),
|
||||
'observer': {'latitude': lat, 'longitude': lon},
|
||||
'prediction_hours': hours,
|
||||
'min_elevation': min_elevation,
|
||||
})
|
||||
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed'
|
||||
}), 503
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error predicting passes: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Pass prediction failed'
|
||||
}), 500
|
||||
|
||||
|
||||
# ========================
|
||||
# Auto-Scheduler Endpoints
|
||||
# ========================
|
||||
|
||||
|
||||
def _scheduler_event_callback(event: dict) -> None:
|
||||
"""Forward scheduler events to the SSE queue."""
|
||||
try:
|
||||
_weather_sat_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
_weather_sat_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/enable', methods=['POST'])
|
||||
def enable_schedule():
|
||||
"""Enable auto-scheduling of weather satellite captures.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"latitude": 51.5, // Required
|
||||
"longitude": -0.1, // Required
|
||||
"min_elevation": 15, // Minimum pass elevation (default: 15)
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain (default: 40)
|
||||
"bias_t": false // Enable bias-T (default: false)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with scheduler status.
|
||||
"""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
if data.get('latitude') is None or data.get('longitude') is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
lat = validate_latitude(data.get('latitude'))
|
||||
lon = validate_longitude(data.get('longitude'))
|
||||
min_elev = validate_elevation(data.get('min_elevation', 15))
|
||||
device = validate_device_index(data.get('device', 0))
|
||||
gain_val = validate_gain(data.get('gain', 40.0))
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid parameter in enable_schedule: %s', e)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler'
|
||||
}), 500
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||
def disable_schedule():
|
||||
"""Disable auto-scheduling."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
result = scheduler.disable()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/status')
|
||||
def schedule_status():
|
||||
"""Get current scheduler state."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
return jsonify(scheduler.get_status())
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/passes')
|
||||
def schedule_passes():
|
||||
"""List scheduled passes."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
passes = scheduler.get_passes()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'passes': passes,
|
||||
'count': len(passes),
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/skip/<pass_id>', methods=['POST'])
|
||||
def skip_pass(pass_id: str):
|
||||
"""Skip a scheduled pass."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
if scheduler.skip_pass(pass_id):
|
||||
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
|
||||
@@ -0,0 +1,504 @@
|
||||
"""HF/Shortwave WebSDR Integration - KiwiSDR network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, Response
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
|
||||
from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.websdr')
|
||||
|
||||
websdr_bp = Blueprint('websdr', __name__, url_prefix='/websdr')
|
||||
|
||||
# ============================================
|
||||
# RECEIVER CACHE
|
||||
# ============================================
|
||||
|
||||
_receiver_cache: list[dict] = []
|
||||
_cache_lock = threading.Lock()
|
||||
_cache_timestamp: float = 0
|
||||
CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def _parse_gps_coord(coord_str: str) -> Optional[float]:
|
||||
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
|
||||
if not coord_str:
|
||||
return None
|
||||
# Remove parentheses and whitespace
|
||||
cleaned = coord_str.strip().strip('()').strip()
|
||||
try:
|
||||
return float(cleaned)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance in km between two GPS coordinates."""
|
||||
R = 6371 # Earth radius in km
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (math.sin(dlat / 2) ** 2 +
|
||||
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
|
||||
math.sin(dlon / 2) ** 2)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
return R * c
|
||||
|
||||
|
||||
KIWI_DATA_URLS = [
|
||||
'https://rx.skywavelinux.com/kiwisdr_com.js',
|
||||
'http://rx.linkfanel.net/kiwisdr_com.js',
|
||||
]
|
||||
|
||||
|
||||
def _fetch_kiwi_receivers() -> list[dict]:
|
||||
"""Fetch the KiwiSDR receiver list from the public directory."""
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
receivers = []
|
||||
raw = None
|
||||
|
||||
# Try each data source until one works
|
||||
for data_url in KIWI_DATA_URLS:
|
||||
try:
|
||||
req = urllib.request.Request(data_url, headers={
|
||||
'User-Agent': 'INTERCEPT-SIGINT/1.0',
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
raw = resp.read().decode('utf-8', errors='replace')
|
||||
if raw and len(raw) > 100:
|
||||
logger.info(f"Fetched KiwiSDR data from {data_url}")
|
||||
break
|
||||
raw = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch from {data_url}: {e}")
|
||||
continue
|
||||
|
||||
if not raw:
|
||||
logger.error("All KiwiSDR data sources failed")
|
||||
return receivers
|
||||
|
||||
# The JS file contains: var kiwisdr_com = [ {...}, {...}, ... ];
|
||||
# Extract the JSON array
|
||||
match = re.search(r'var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?', raw, re.DOTALL)
|
||||
if not match:
|
||||
# Try bare array
|
||||
match = re.search(r'(\[\s*\{.*\}\s*\])', raw, re.DOTALL)
|
||||
if not match:
|
||||
logger.warning("Could not find receiver array in KiwiSDR data")
|
||||
return receivers
|
||||
|
||||
arr_str = match.group(1)
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
raw_list = json.loads(arr_str)
|
||||
except json.JSONDecodeError:
|
||||
# Fix common JS → JSON issues (trailing commas)
|
||||
fixed = re.sub(r',\s*}', '}', arr_str)
|
||||
fixed = re.sub(r',\s*]', ']', fixed)
|
||||
try:
|
||||
raw_list = json.loads(fixed)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse KiwiSDR JSON")
|
||||
return receivers
|
||||
|
||||
for entry in raw_list:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
# Skip offline receivers
|
||||
if entry.get('offline') == 'yes' or entry.get('status') != 'active':
|
||||
continue
|
||||
|
||||
name = entry.get('name', 'Unknown')
|
||||
url = entry.get('url', '')
|
||||
gps = entry.get('gps', '')
|
||||
antenna = entry.get('antenna', '')
|
||||
location = entry.get('loc', '')
|
||||
|
||||
# Parse users (strings in actual data)
|
||||
try:
|
||||
users = int(entry.get('users', 0))
|
||||
except (ValueError, TypeError):
|
||||
users = 0
|
||||
try:
|
||||
users_max = int(entry.get('users_max', 4))
|
||||
except (ValueError, TypeError):
|
||||
users_max = 4
|
||||
|
||||
# Parse bands field: "0-30000000" (Hz) → freq_lo/freq_hi in kHz
|
||||
bands_str = entry.get('bands', '0-30000000')
|
||||
freq_lo = 0
|
||||
freq_hi = 30000
|
||||
if bands_str and '-' in str(bands_str):
|
||||
try:
|
||||
parts = str(bands_str).split('-')
|
||||
freq_lo = int(parts[0]) / 1000 # Hz to kHz
|
||||
freq_hi = int(parts[1]) / 1000 # Hz to kHz
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Parse GPS: "(51.317266, -2.950479)" format
|
||||
lat, lon = None, None
|
||||
if gps:
|
||||
parts = str(gps).replace('(', '').replace(')', '').split(',')
|
||||
if len(parts) >= 2:
|
||||
lat = _parse_gps_coord(parts[0])
|
||||
lon = _parse_gps_coord(parts[1])
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
# Ensure URL has protocol
|
||||
if not url.startswith('http'):
|
||||
url = 'http://' + url
|
||||
|
||||
receivers.append({
|
||||
'name': name,
|
||||
'url': url.rstrip('/'),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'location': location,
|
||||
'users': users,
|
||||
'users_max': users_max,
|
||||
'antenna': antenna,
|
||||
'bands': bands_str,
|
||||
'freq_lo': freq_lo,
|
||||
'freq_hi': freq_hi,
|
||||
'available': users < users_max,
|
||||
})
|
||||
|
||||
return receivers
|
||||
|
||||
|
||||
def get_receivers(force_refresh: bool = False) -> list[dict]:
|
||||
"""Get cached receiver list, refreshing if stale."""
|
||||
global _receiver_cache, _cache_timestamp
|
||||
|
||||
with _cache_lock:
|
||||
now = time.time()
|
||||
if force_refresh or not _receiver_cache or (now - _cache_timestamp) > CACHE_TTL:
|
||||
logger.info("Refreshing KiwiSDR receiver list...")
|
||||
_receiver_cache = _fetch_kiwi_receivers()
|
||||
_cache_timestamp = now
|
||||
logger.info(f"Loaded {len(_receiver_cache)} KiwiSDR receivers")
|
||||
|
||||
return _receiver_cache
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@websdr_bp.route('/receivers')
|
||||
def list_receivers() -> Response:
|
||||
"""List KiwiSDR receivers, with optional filters."""
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
available = request.args.get('available', type=str)
|
||||
refresh = request.args.get('refresh', type=str)
|
||||
|
||||
receivers = get_receivers(force_refresh=(refresh == 'true'))
|
||||
|
||||
filtered = receivers
|
||||
if available == 'true':
|
||||
filtered = [r for r in filtered if r.get('available', True)]
|
||||
|
||||
if freq_khz is not None:
|
||||
filtered = [
|
||||
r for r in filtered
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': filtered[:100],
|
||||
'total': len(filtered),
|
||||
'cached_total': len(receivers),
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/receivers/nearest')
|
||||
def nearest_receivers() -> Response:
|
||||
"""Find receivers nearest to a given location."""
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
# Filter by frequency if specified
|
||||
if freq_khz is not None:
|
||||
receivers = [
|
||||
r for r in receivers
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
# Calculate distances and sort
|
||||
with_distance = []
|
||||
for r in receivers:
|
||||
if r.get('lat') is not None and r.get('lon') is not None:
|
||||
dist = _haversine(lat, lon, r['lat'], r['lon'])
|
||||
entry = dict(r)
|
||||
entry['distance_km'] = round(dist, 1)
|
||||
with_distance.append(entry)
|
||||
|
||||
with_distance.sort(key=lambda x: x['distance_km'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': with_distance[:10],
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/spy-station/<station_id>/receivers')
|
||||
def spy_station_receivers(station_id: str) -> Response:
|
||||
"""Find receivers that can tune to a spy station's frequency."""
|
||||
try:
|
||||
from routes.spy_stations import STATIONS
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503
|
||||
|
||||
# Find the station
|
||||
station = None
|
||||
for s in STATIONS:
|
||||
if s.get('id') == station_id:
|
||||
station = s
|
||||
break
|
||||
|
||||
if not station:
|
||||
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
|
||||
|
||||
# Get primary frequency
|
||||
freq_khz = None
|
||||
for f in station.get('frequencies', []):
|
||||
if f.get('primary'):
|
||||
freq_khz = f.get('freq_khz')
|
||||
break
|
||||
if freq_khz is None and station.get('frequencies'):
|
||||
freq_khz = station['frequencies'][0].get('freq_khz')
|
||||
|
||||
if freq_khz is None:
|
||||
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
# Filter receivers that cover this frequency and are available
|
||||
matching = [
|
||||
r for r in receivers
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'station': {
|
||||
'id': station['id'],
|
||||
'name': station.get('name', ''),
|
||||
'nickname': station.get('nickname', ''),
|
||||
'freq_khz': freq_khz,
|
||||
'mode': station.get('mode', 'USB'),
|
||||
},
|
||||
'receivers': matching[:20],
|
||||
'total': len(matching),
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/status')
|
||||
def websdr_status() -> Response:
|
||||
"""Get WebSDR connection and cache status."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'cached_receivers': len(_receiver_cache),
|
||||
'cache_age_seconds': round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None,
|
||||
'cache_ttl': CACHE_TTL,
|
||||
'audio_connected': _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False,
|
||||
})
|
||||
|
||||
|
||||
# ============================================
|
||||
# KIWISDR AUDIO PROXY
|
||||
# ============================================
|
||||
|
||||
_kiwi_client: Optional[KiwiSDRClient] = None
|
||||
_kiwi_lock = threading.Lock()
|
||||
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _disconnect_kiwi() -> None:
|
||||
"""Disconnect active KiwiSDR client."""
|
||||
global _kiwi_client
|
||||
with _kiwi_lock:
|
||||
if _kiwi_client:
|
||||
_kiwi_client.disconnect()
|
||||
_kiwi_client = None
|
||||
# Drain audio queue
|
||||
while not _kiwi_audio_queue.empty():
|
||||
try:
|
||||
_kiwi_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
|
||||
"""Handle a command from the browser client."""
|
||||
global _kiwi_client
|
||||
|
||||
if cmd == 'connect':
|
||||
receiver_url = data.get('url', '')
|
||||
host = data.get('host', '')
|
||||
port = int(data.get('port', 8073))
|
||||
freq_khz = float(data.get('freq_khz', 7000))
|
||||
mode = data.get('mode', 'am').lower()
|
||||
password = data.get('password', '')
|
||||
|
||||
# Parse host/port from URL if provided
|
||||
if receiver_url and not host:
|
||||
host, port = parse_host_port(receiver_url)
|
||||
|
||||
if mode not in VALID_MODES:
|
||||
ws.send(json.dumps({'type': 'error', 'message': f'Invalid mode: {mode}'}))
|
||||
return
|
||||
|
||||
if not host or ';' in host or '&' in host or '|' in host:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Invalid host'}))
|
||||
return
|
||||
|
||||
_disconnect_kiwi()
|
||||
|
||||
def on_audio(pcm_bytes, smeter):
|
||||
# Package: 2 bytes smeter (big-endian int16) + PCM data
|
||||
header = struct.pack('>h', smeter)
|
||||
try:
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
try:
|
||||
_kiwi_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def on_error(msg):
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'error', 'message': msg}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_disconnect():
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'disconnected'}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _kiwi_lock:
|
||||
_kiwi_client = KiwiSDRClient(
|
||||
host=host, port=port,
|
||||
on_audio=on_audio,
|
||||
on_error=on_error,
|
||||
on_disconnect=on_disconnect,
|
||||
password=password,
|
||||
)
|
||||
success = _kiwi_client.connect(freq_khz, mode)
|
||||
|
||||
if success:
|
||||
ws.send(json.dumps({
|
||||
'type': 'connected',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'freq_khz': freq_khz,
|
||||
'mode': mode,
|
||||
'sample_rate': KIWI_SAMPLE_RATE,
|
||||
}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Connection to KiwiSDR failed'}))
|
||||
_disconnect_kiwi()
|
||||
|
||||
elif cmd == 'tune':
|
||||
freq_khz = float(data.get('freq_khz', 0))
|
||||
mode = data.get('mode', '').lower() or None
|
||||
|
||||
with _kiwi_lock:
|
||||
if _kiwi_client and _kiwi_client.connected:
|
||||
success = _kiwi_client.tune(
|
||||
freq_khz,
|
||||
mode or _kiwi_client.mode
|
||||
)
|
||||
if success:
|
||||
ws.send(json.dumps({
|
||||
'type': 'tuned',
|
||||
'freq_khz': freq_khz,
|
||||
'mode': mode or _kiwi_client.mode,
|
||||
}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Retune failed'}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Not connected'}))
|
||||
|
||||
elif cmd == 'disconnect':
|
||||
_disconnect_kiwi()
|
||||
ws.send(json.dumps({'type': 'disconnected'}))
|
||||
|
||||
|
||||
def init_websdr_audio(app: Flask) -> None:
|
||||
"""Initialize WebSocket audio proxy for KiwiSDR. Called from app.py."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, KiwiSDR audio proxy disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/kiwi-audio')
|
||||
def kiwi_audio_stream(ws):
|
||||
"""WebSocket endpoint: proxy audio between browser and KiwiSDR."""
|
||||
logger.info("KiwiSDR audio client connected")
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for commands from browser
|
||||
try:
|
||||
msg = ws.receive(timeout=0.005)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
cmd = data.get('cmd', '')
|
||||
_handle_kiwi_command(ws, cmd, data)
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if 'closed' in str(e).lower():
|
||||
break
|
||||
if 'timed out' not in str(e).lower():
|
||||
logger.error(f"KiwiSDR WS receive error: {e}")
|
||||
|
||||
# Forward audio from KiwiSDR to browser
|
||||
try:
|
||||
audio_data = _kiwi_audio_queue.get_nowait()
|
||||
ws.send(audio_data)
|
||||
except queue.Empty:
|
||||
time.sleep(0.005)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"KiwiSDR WS closed: {e}")
|
||||
finally:
|
||||
_disconnect_kiwi()
|
||||
logger.info("KiwiSDR audio client disconnected")
|
||||
@@ -17,11 +17,12 @@ from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from data.oui import get_manufacturer
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse, sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
WIFI_TERMINATE_TIMEOUT,
|
||||
PMKID_TERMINATE_TIMEOUT,
|
||||
@@ -46,8 +47,33 @@ from utils.constants import (
|
||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||
|
||||
# PMKID process state
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||
"""Parse a channel list from string/list input."""
|
||||
if raw_channels in (None, '', []):
|
||||
return None
|
||||
|
||||
if isinstance(raw_channels, str):
|
||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||
elif isinstance(raw_channels, (list, tuple, set)):
|
||||
parts = list(raw_channels)
|
||||
else:
|
||||
parts = [raw_channels]
|
||||
|
||||
channels: list[int] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if part in (None, ''):
|
||||
continue
|
||||
ch = validate_wifi_channel(part)
|
||||
if ch not in seen:
|
||||
channels.append(ch)
|
||||
seen.add(ch)
|
||||
|
||||
return channels or None
|
||||
|
||||
|
||||
def detect_wifi_interfaces():
|
||||
@@ -607,8 +633,9 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
|
||||
data = request.json
|
||||
channel = data.get('channel')
|
||||
band = data.get('band', 'abg')
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
@@ -658,8 +685,17 @@ def start_wifi_scan():
|
||||
interface
|
||||
]
|
||||
|
||||
if channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
channel_list = None
|
||||
if channels:
|
||||
try:
|
||||
channel_list = _parse_channel_list(channels)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -851,32 +887,53 @@ def check_handshake_status():
|
||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
handshake_found = False
|
||||
handshake_found = False
|
||||
handshake_valid: bool | None = None
|
||||
handshake_checked = False
|
||||
handshake_reason: str | None = None
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
output_lower = output.lower()
|
||||
handshake_checked = True
|
||||
|
||||
if 'no valid wpa handshakes found' in output_lower:
|
||||
handshake_valid = False
|
||||
handshake_reason = 'No valid WPA handshake found'
|
||||
elif '0 handshake' in output_lower:
|
||||
handshake_valid = False
|
||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||
handshake_valid = True
|
||||
else:
|
||||
handshake_valid = False
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
if handshake_valid:
|
||||
handshake_found = True
|
||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||
app_module.wifi_handshakes.append(normalized_bssid)
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found,
|
||||
'handshake_valid': handshake_valid,
|
||||
'handshake_checked': handshake_checked,
|
||||
'handshake_reason': handshake_reason
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||
@@ -1075,29 +1132,26 @@ def get_wifi_networks():
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.wifi_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.wifi_queue,
|
||||
channel_key='wifi',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1240,10 +1294,32 @@ def v2_get_networks():
|
||||
|
||||
@wifi_bp.route('/v2/clients')
|
||||
def v2_get_clients():
|
||||
"""Get all discovered clients."""
|
||||
"""Get discovered clients with optional filtering."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
|
||||
# Filter by association status
|
||||
associated = request.args.get('associated')
|
||||
if associated == 'true':
|
||||
clients = [c for c in clients if c.is_associated]
|
||||
elif associated == 'false':
|
||||
clients = [c for c in clients if not c.is_associated]
|
||||
|
||||
# Filter by associated BSSID
|
||||
bssid = request.args.get('bssid')
|
||||
if bssid:
|
||||
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
|
||||
|
||||
# Filter by minimum RSSI
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'clients': [c.to_dict() for c in clients],
|
||||
'total': len(clients),
|
||||
@@ -1462,8 +1538,8 @@ def v2_deauth_status():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
"""
|
||||
SSE stream for real-time deauth alerts.
|
||||
|
||||
@@ -1474,26 +1550,18 @@ def v2_deauth_stream():
|
||||
- deauth_error: An error occurred
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Try to get from the dedicated deauth queue
|
||||
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.deauth_detector_queue,
|
||||
channel_key='wifi_deauth',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,28 +87,44 @@ def start_deep_scan():
|
||||
|
||||
Requires monitor mode interface and root privileges.
|
||||
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
channels: Optional list or comma-separated channels to monitor
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = int(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'Invalid channels'}), 400
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = validate_wifi_channel(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
)
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
channels=channel_list,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
@@ -388,10 +406,14 @@ def event_stream():
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
yield format_sse(event)
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
try:
|
||||
process_event('wifi', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
|
||||
@@ -137,6 +137,14 @@ need_sudo() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Refresh sudo credential cache so long-running builds don't trigger
|
||||
# mid-compilation password prompts (which can fail due to TTY issues
|
||||
# inside subshells). Safe to call multiple times.
|
||||
refresh_sudo() {
|
||||
[[ -z "${SUDO:-}" ]] && return 0
|
||||
sudo -v 2>/dev/null || true
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
@@ -165,6 +173,7 @@ detect_dragonos() {
|
||||
# Required tool checks (with alternates)
|
||||
# ----------------------------
|
||||
missing_required=()
|
||||
missing_recommended=()
|
||||
|
||||
check_required() {
|
||||
local label="$1"; shift
|
||||
@@ -178,6 +187,18 @@ check_required() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_recommended() {
|
||||
local label="$1"; shift
|
||||
local desc="$1"; shift
|
||||
|
||||
if have_any "$@"; then
|
||||
ok "${label} - ${desc}"
|
||||
else
|
||||
warn "${label} - ${desc} (missing, recommended)"
|
||||
missing_recommended+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
check_optional() {
|
||||
local label="$1"; shift
|
||||
local desc="$1"; shift
|
||||
@@ -201,11 +222,13 @@ check_tools() {
|
||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
|
||||
check_optional "hackrf_transfer" "HackRF SubGHz transceiver" hackrf_transfer
|
||||
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
|
||||
|
||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
@@ -286,28 +309,40 @@ install_python_deps() {
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
local PIP="venv/bin/python -m pip"
|
||||
local PY="venv/bin/python"
|
||||
|
||||
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
$PIP install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
ok "Upgraded pip tooling"
|
||||
|
||||
progress "Installing Python dependencies"
|
||||
# Try pip install, but don't fail if apt packages already satisfied deps
|
||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
||||
# Verify critical packages are available
|
||||
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||
echo "Try: pip install flask requests flask-limiter"
|
||||
exit 1
|
||||
}
|
||||
ok "Core Python dependencies available"
|
||||
else
|
||||
ok "Python dependencies installed"
|
||||
fi
|
||||
|
||||
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility)
|
||||
# System apt packages may have older Flask 2.x which is incompatible
|
||||
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
|
||||
# Install critical packages first to avoid all-or-nothing failures
|
||||
# (C extension packages like scipy/numpy can fail on newer Python versions
|
||||
# and cause pip to roll back pure-Python packages like flask)
|
||||
info "Installing core packages..."
|
||||
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||
"Werkzeug>=3.1.5" "pyserial>=3.5" "flask-sock" "websocket-client>=1.6.0" 2>/dev/null || true
|
||||
|
||||
# Verify critical packages
|
||||
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||
echo "Try: venv/bin/pip install flask requests flask-limiter"
|
||||
exit 1
|
||||
}
|
||||
ok "Core Python packages installed"
|
||||
|
||||
# Install optional packages individually (some may fail on newer Python)
|
||||
info "Installing optional packages..."
|
||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
|
||||
"bleak>=0.21.0" "psycopg2-binary>=2.9.9" "meshtastic>=2.0.0" \
|
||||
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0"; do
|
||||
pkg_name="${pkg%%>=*}"
|
||||
if ! $PIP install "$pkg" 2>/dev/null; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
ok "Optional packages processed"
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -370,7 +405,7 @@ install_rtlamr_from_source() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
else
|
||||
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
fi
|
||||
else
|
||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
@@ -386,48 +421,6 @@ install_rtlamr_from_source() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_slowrx_from_source_macos() {
|
||||
info "slowrx not available via Homebrew. Building from source..."
|
||||
|
||||
# Ensure build dependencies are installed
|
||||
brew_install cmake
|
||||
brew_install fftw
|
||||
brew_install libsndfile
|
||||
brew_install gtk+3
|
||||
brew_install pkg-config
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning slowrx..."
|
||||
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone slowrx"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/slowrx"
|
||||
info "Compiling slowrx..."
|
||||
mkdir -p build && cd build
|
||||
local cmake_log make_log
|
||||
cmake_log=$(cmake .. 2>&1) || {
|
||||
warn "cmake failed for slowrx:"
|
||||
echo "$cmake_log" | tail -20
|
||||
exit 1
|
||||
}
|
||||
make_log=$(make 2>&1) || {
|
||||
warn "make failed for slowrx:"
|
||||
echo "$make_log" | tail -20
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Install to /usr/local/bin
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 slowrx /usr/local/bin/slowrx
|
||||
else
|
||||
sudo install -m 0755 slowrx /usr/local/bin/slowrx
|
||||
fi
|
||||
ok "slowrx installed successfully from source"
|
||||
)
|
||||
}
|
||||
|
||||
install_multimon_ng_from_source_macos() {
|
||||
info "multimon-ng not available via Homebrew. Building from source..."
|
||||
@@ -454,14 +447,385 @@ install_multimon_ng_from_source_macos() {
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
else
|
||||
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
fi
|
||||
ok "multimon-ng installed successfully from source"
|
||||
)
|
||||
}
|
||||
|
||||
install_dump1090_from_source_macos() {
|
||||
info "dump1090 not available via Homebrew. Building from source..."
|
||||
|
||||
brew_install cmake
|
||||
brew_install librtlsdr
|
||||
brew_install pkg-config
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning FlightAware dump1090..."
|
||||
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone dump1090"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
sed -i '' 's/-Werror//g' Makefile 2>/dev/null || true
|
||||
info "Compiling dump1090..."
|
||||
if make BLADERF=no RTLSDR=yes 2>&1 | tail -5; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
fi
|
||||
ok "dump1090 installed successfully from source"
|
||||
else
|
||||
warn "Failed to build dump1090. ADS-B decoding will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_acarsdec_from_source_macos() {
|
||||
info "acarsdec not available via Homebrew. Building from source..."
|
||||
|
||||
brew_install cmake
|
||||
brew_install librtlsdr
|
||||
brew_install libsndfile
|
||||
brew_install pkg-config
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning acarsdec..."
|
||||
git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone acarsdec"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/acarsdec"
|
||||
|
||||
# Fix compiler flags for macOS Apple Silicon (ARM64)
|
||||
# -march=native can fail with Apple Clang on M-series chips
|
||||
# -Ofast is deprecated in modern Clang
|
||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
|
||||
info "Patched compiler flags for Apple Silicon (arm64)"
|
||||
fi
|
||||
|
||||
# Fix pthread_tryjoin_np (Linux-only GNU extension) for macOS
|
||||
# Replace with pthread_join which provides equivalent behavior
|
||||
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
||||
sed -i '' 's/pthread_tryjoin_np(\([^,]*\), NULL)/pthread_join(\1, NULL)/g' rtl.c
|
||||
info "Patched pthread_tryjoin_np for macOS compatibility"
|
||||
fi
|
||||
|
||||
# Fix libacars linking on macOS (upstream issue #112)
|
||||
# Use LIBACARS_LINK_LIBRARIES (full path) instead of LIBACARS_LIBRARIES (name only)
|
||||
if grep -q 'LIBACARS_LIBRARIES' CMakeLists.txt 2>/dev/null; then
|
||||
sed -i '' 's/${LIBACARS_LIBRARIES}/${LIBACARS_LINK_LIBRARIES}/g' CMakeLists.txt
|
||||
info "Patched libacars linking for macOS"
|
||||
fi
|
||||
|
||||
mkdir -p build && cd build
|
||||
|
||||
# Set Homebrew paths for Apple Silicon (/opt/homebrew) or Intel (/usr/local)
|
||||
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
|
||||
|
||||
info "Compiling acarsdec..."
|
||||
build_log="$tmp_dir/acarsdec-build.log"
|
||||
if cmake .. -Drtl=ON \
|
||||
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||
>"$build_log" 2>&1 \
|
||||
&& make >>"$build_log" 2>&1; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
fi
|
||||
ok "acarsdec installed successfully from source"
|
||||
else
|
||||
warn "Failed to build acarsdec. ACARS decoding will not be available."
|
||||
warn "Build log (last 30 lines):"
|
||||
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_dumpvdl2_from_source_macos() {
|
||||
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||
|
||||
brew_install cmake
|
||||
brew_install librtlsdr
|
||||
brew_install pkg-config
|
||||
brew_install glib
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
|
||||
|
||||
# Build libacars first
|
||||
info "Cloning libacars..."
|
||||
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone libacars"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/libacars"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling libacars..."
|
||||
build_log="$tmp_dir/libacars-build.log"
|
||||
if cmake .. \
|
||||
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||
>"$build_log" 2>&1 \
|
||||
&& make >>"$build_log" 2>&1; then
|
||||
if [[ -w /usr/local/lib ]]; then
|
||||
make install >>"$build_log" 2>&1
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO make install >>"$build_log" 2>&1
|
||||
fi
|
||||
ok "libacars installed"
|
||||
else
|
||||
warn "Failed to build libacars."
|
||||
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build dumpvdl2
|
||||
info "Cloning dumpvdl2..."
|
||||
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dumpvdl2"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling dumpvdl2..."
|
||||
build_log="$tmp_dir/dumpvdl2-build.log"
|
||||
if cmake .. \
|
||||
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||
>"$build_log" 2>&1 \
|
||||
&& make >>"$build_log" 2>&1; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||
fi
|
||||
ok "dumpvdl2 installed successfully from source"
|
||||
else
|
||||
warn "Failed to build dumpvdl2. VDL2 decoding will not be available."
|
||||
warn "Build log (last 30 lines):"
|
||||
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_aiscatcher_from_source_macos() {
|
||||
info "AIS-catcher not available via Homebrew. Building from source..."
|
||||
|
||||
brew_install cmake
|
||||
brew_install librtlsdr
|
||||
brew_install curl
|
||||
brew_install pkg-config
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning AIS-catcher..."
|
||||
git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone AIS-catcher"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/AIS-catcher"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling AIS-catcher..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||
fi
|
||||
ok "AIS-catcher installed successfully from source"
|
||||
else
|
||||
warn "Failed to build AIS-catcher. AIS vessel tracking will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_satdump_from_source_debian() {
|
||||
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||
|
||||
# Core deps — hard-fail if missing
|
||||
apt_install build-essential git cmake pkg-config \
|
||||
libpng-dev libtiff-dev libzstd-dev \
|
||||
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
||||
|
||||
# libvolk: package name differs between distros
|
||||
# Ubuntu / Debian Trixie+: libvolk-dev
|
||||
# Raspberry Pi OS Bookworm / Debian Bookworm: libvolk2-dev
|
||||
apt_try_install_any libvolk-dev libvolk2-dev \
|
||||
|| warn "libvolk not found — SatDump will build without VOLK acceleration"
|
||||
|
||||
# Optional SDR hardware libs — soft-fail so missing hardware doesn't abort
|
||||
for pkg in libjemalloc-dev libnng-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev; do
|
||||
$SUDO apt-get install -y --no-install-recommends "$pkg" >/dev/null 2>&1 \
|
||||
|| warn "${pkg} not available — skipping (SatDump can build without it)"
|
||||
done
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning SatDump v1.2.2..."
|
||||
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/SatDump"
|
||||
|
||||
# Patch: fix deprecated std::allocator usage for newer compilers
|
||||
# GCC 13+ errors on deprecated allocator members in sol2.
|
||||
# Pragmas must go in lua_utils.cpp (the instantiation site), not sol.hpp (definition site).
|
||||
lua_utils="src-core/common/lua/lua_utils.cpp"
|
||||
if [ -f "$lua_utils" ]; then
|
||||
{
|
||||
echo '#pragma GCC diagnostic push'
|
||||
echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
|
||||
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
|
||||
cat "$lua_utils"
|
||||
echo # ensure the file ends with a newline before the closing pragma
|
||||
echo '#pragma GCC diagnostic pop'
|
||||
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
|
||||
fi
|
||||
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
||||
build_log="$tmp_dir/satdump-build.log"
|
||||
|
||||
# Show periodic progress while building so the user knows it's not hung
|
||||
(
|
||||
while true; do
|
||||
sleep 30
|
||||
if [ -f "$build_log" ]; then
|
||||
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
|
||||
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
|
||||
fi
|
||||
done
|
||||
) &
|
||||
progress_pid=$!
|
||||
|
||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >"$build_log" 2>&1 \
|
||||
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
|
||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig
|
||||
|
||||
# Ensure plugins are in the expected path (handles multiarch differences)
|
||||
$SUDO mkdir -p /usr/local/lib/satdump/plugins
|
||||
if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then
|
||||
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do
|
||||
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then
|
||||
$SUDO ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
ok "SatDump installed successfully."
|
||||
else
|
||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||
warn "Build log (last 30 lines):"
|
||||
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_satdump_macos() {
|
||||
info "Installing SatDump v1.2.2 from pre-built release (weather satellite decoder)..."
|
||||
|
||||
# Determine architecture
|
||||
local arch
|
||||
arch="$(uname -m)"
|
||||
local dmg_name
|
||||
if [ "$arch" = "arm64" ]; then
|
||||
dmg_name="SatDump-macOS-Silicon.dmg"
|
||||
else
|
||||
dmg_name="SatDump-macOS-Intel.dmg"
|
||||
fi
|
||||
|
||||
local dmg_url="https://github.com/SatDump/SatDump/releases/download/1.2.2/${dmg_name}"
|
||||
local install_dir="/usr/local/lib/satdump"
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null || true; rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Downloading ${dmg_name}..."
|
||||
if ! curl -sL -o "$tmp_dir/satdump.dmg" "$dmg_url"; then
|
||||
warn "Failed to download SatDump. Weather satellite decoding will not be available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Installing SatDump..."
|
||||
# Mount the DMG
|
||||
hdiutil attach "$tmp_dir/satdump.dmg" -nobrowse -quiet -mountpoint "$tmp_dir/mnt" \
|
||||
|| { warn "Failed to mount SatDump DMG"; exit 1; }
|
||||
|
||||
local app_dir="$tmp_dir/mnt/SatDump.app"
|
||||
if [ ! -d "$app_dir" ]; then
|
||||
warn "SatDump.app not found in DMG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install: copy app contents to /usr/local/lib/satdump
|
||||
refresh_sudo
|
||||
$SUDO mkdir -p "$install_dir"
|
||||
$SUDO cp -R "$app_dir/Contents/MacOS/"* "$install_dir/"
|
||||
$SUDO cp -R "$app_dir/Contents/Resources/"* "$install_dir/"
|
||||
|
||||
# Create wrapper script so satdump can find its resources via @executable_path
|
||||
$SUDO tee /usr/local/bin/satdump >/dev/null <<'WRAPPER'
|
||||
#!/bin/sh
|
||||
exec /usr/local/lib/satdump/satdump "$@"
|
||||
WRAPPER
|
||||
$SUDO chmod +x /usr/local/bin/satdump
|
||||
|
||||
hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null
|
||||
|
||||
# Verify installation
|
||||
if /usr/local/lib/satdump/satdump 2>&1 | grep -q "Usage"; then
|
||||
ok "SatDump v1.2.2 installed successfully."
|
||||
else
|
||||
warn "SatDump installed but may not work correctly."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_macos_packages() {
|
||||
TOTAL_STEPS=16
|
||||
need_sudo
|
||||
|
||||
# Prime sudo credentials upfront so builds don't prompt mid-compilation
|
||||
if [[ -n "${SUDO:-}" ]]; then
|
||||
info "Some tools require sudo to install. You may be prompted for your password."
|
||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=21
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -481,12 +845,8 @@ install_macos_packages() {
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||
|
||||
progress "Installing slowrx (SSTV decoder)"
|
||||
if ! cmd_exists slowrx; then
|
||||
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
|
||||
else
|
||||
ok "slowrx already installed"
|
||||
fi
|
||||
progress "SSTV decoder"
|
||||
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
brew_install ffmpeg
|
||||
@@ -494,6 +854,9 @@ install_macos_packages() {
|
||||
progress "Installing rtl_433"
|
||||
brew_install rtl_433
|
||||
|
||||
progress "Installing HackRF tools"
|
||||
brew_install hackrf
|
||||
|
||||
progress "Installing rtlamr (optional)"
|
||||
# rtlamr is optional - used for utility meter monitoring
|
||||
if ! cmd_exists rtlamr; then
|
||||
@@ -509,18 +872,46 @@ install_macos_packages() {
|
||||
fi
|
||||
|
||||
progress "Installing dump1090"
|
||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||
if ! cmd_exists dump1090; then
|
||||
(brew_install dump1090-mutability) || install_dump1090_from_source_macos || warn "dump1090 not available"
|
||||
else
|
||||
ok "dump1090 already installed"
|
||||
fi
|
||||
|
||||
progress "Installing acarsdec"
|
||||
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||
if ! cmd_exists acarsdec; then
|
||||
(brew_install acarsdec) || install_acarsdec_from_source_macos || warn "acarsdec not available"
|
||||
else
|
||||
ok "acarsdec already installed"
|
||||
fi
|
||||
|
||||
progress "Installing dumpvdl2"
|
||||
if ! cmd_exists dumpvdl2; then
|
||||
install_dumpvdl2_from_source_macos || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||
else
|
||||
ok "dumpvdl2 already installed"
|
||||
fi
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
|
||||
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
||||
else
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
|
||||
progress "Installing SatDump (optional)"
|
||||
if ! cmd_exists satdump; then
|
||||
echo
|
||||
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||
if ask_yes_no "Do you want to install SatDump?"; then
|
||||
install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available."
|
||||
else
|
||||
warn "Skipping SatDump installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "SatDump already installed"
|
||||
fi
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
brew_install aircrack-ng
|
||||
|
||||
@@ -594,10 +985,13 @@ install_dump1090_from_source_debian() {
|
||||
librtlsdr-dev libusb-1.0-0-dev \
|
||||
libncurses-dev tcl-dev python3-dev
|
||||
|
||||
local JOBS
|
||||
JOBS="$(nproc 2>/dev/null || echo 1)"
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
trap '{ [[ -n "${progress_pid:-}" ]] && kill "$progress_pid" 2>/dev/null && wait "$progress_pid" 2>/dev/null || true; }; rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning FlightAware dump1090..."
|
||||
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
@@ -605,23 +999,45 @@ install_dump1090_from_source_debian() {
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
# Remove -Werror to prevent build failures on newer GCC versions
|
||||
sed -i 's/-Werror//g' Makefile 2>/dev/null || sed -i '' 's/-Werror//g' Makefile
|
||||
info "Compiling FlightAware dump1090..."
|
||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||
sed -i 's/-Werror//g' Makefile 2>/dev/null || true
|
||||
info "Compiling FlightAware dump1090 (using ${JOBS} CPU cores)..."
|
||||
build_log="$tmp_dir/dump1090-build.log"
|
||||
|
||||
(while true; do sleep 20; printf " [*] Still compiling dump1090...\n"; done) &
|
||||
progress_pid=$!
|
||||
|
||||
if make -j "$JOBS" BLADERF=no RTLSDR=yes >"$build_log" 2>&1; then
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (FlightAware)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
||||
warn "Build log (last 20 lines):"
|
||||
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
|
||||
rm -rf "$tmp_dir/dump1090"
|
||||
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
info "Compiling readsb..."
|
||||
make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; }
|
||||
info "Compiling readsb (using ${JOBS} CPU cores)..."
|
||||
build_log="$tmp_dir/readsb-build.log"
|
||||
|
||||
(while true; do sleep 20; printf " [*] Still compiling readsb...\n"; done) &
|
||||
progress_pid=$!
|
||||
|
||||
if ! make -j "$JOBS" RTLSDR=yes >"$build_log" 2>&1; then
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
warn "Build log (last 20 lines):"
|
||||
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
fail "Failed to build readsb from source (required)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (via readsb)."
|
||||
)
|
||||
@@ -646,7 +1062,7 @@ install_acarsdec_from_source_debian() {
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling acarsdec..."
|
||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
if cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
ok "acarsdec installed successfully."
|
||||
else
|
||||
@@ -655,6 +1071,52 @@ install_acarsdec_from_source_debian() {
|
||||
)
|
||||
}
|
||||
|
||||
install_dumpvdl2_from_source_debian() {
|
||||
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||
|
||||
apt_install build-essential git cmake \
|
||||
librtlsdr-dev libusb-1.0-0-dev libglib2.0-dev libxml2-dev
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
# Build libacars first
|
||||
info "Cloning libacars..."
|
||||
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone libacars"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/libacars"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling libacars..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig
|
||||
ok "libacars installed"
|
||||
else
|
||||
warn "Failed to build libacars."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build dumpvdl2
|
||||
info "Cloning dumpvdl2..."
|
||||
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dumpvdl2"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling dumpvdl2..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||
ok "dumpvdl2 installed successfully."
|
||||
else
|
||||
warn "Failed to build dumpvdl2 from source. VDL2 decoding will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_aiscatcher_from_source_debian() {
|
||||
info "AIS-catcher not available via APT. Building from source..."
|
||||
|
||||
@@ -683,37 +1145,6 @@ install_aiscatcher_from_source_debian() {
|
||||
)
|
||||
}
|
||||
|
||||
install_slowrx_from_source_debian() {
|
||||
info "slowrx not available via APT. Building from source..."
|
||||
|
||||
# slowrx uses a simple Makefile, not CMake
|
||||
apt_install build-essential git pkg-config \
|
||||
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning slowrx..."
|
||||
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone slowrx"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/slowrx"
|
||||
|
||||
info "Compiling slowrx..."
|
||||
local make_log
|
||||
make_log=$(make 2>&1) || {
|
||||
warn "make failed for slowrx:"
|
||||
echo "$make_log" | tail -20
|
||||
warn "ISS SSTV decoding will not be available."
|
||||
exit 1
|
||||
}
|
||||
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
|
||||
ok "slowrx installed successfully."
|
||||
)
|
||||
}
|
||||
|
||||
install_ubertooth_from_source_debian() {
|
||||
info "Building Ubertooth from source..."
|
||||
|
||||
@@ -778,6 +1209,17 @@ install_rtlsdr_blog_drivers_debian() {
|
||||
$SUDO udevadm trigger || true
|
||||
fi
|
||||
|
||||
# Make the Blog drivers' library take priority over the apt-installed
|
||||
# librtlsdr. Removing apt packages is too destructive (dump1090-mutability
|
||||
# and other tools depend on librtlsdr0 and get swept out). Instead,
|
||||
# prepend /usr/local/lib to ldconfig's search path — files named 00-*
|
||||
# sort before the distro's aarch64-linux-gnu.conf — so ldconfig lists
|
||||
# /usr/local/lib/librtlsdr.so.0 first and the dynamic linker uses it.
|
||||
if [[ -d /etc/ld.so.conf.d ]]; then
|
||||
echo '/usr/local/lib' | $SUDO tee /etc/ld.so.conf.d/00-local-first.conf >/dev/null
|
||||
fi
|
||||
$SUDO ldconfig
|
||||
|
||||
ok "RTL-SDR Blog drivers installed successfully."
|
||||
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
||||
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
||||
@@ -787,6 +1229,7 @@ install_rtlsdr_blog_drivers_debian() {
|
||||
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
||||
fi
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
setup_udev_rules_debian() {
|
||||
@@ -811,24 +1254,35 @@ blacklist_kernel_drivers_debian() {
|
||||
|
||||
if [[ -f "$blacklist_file" ]]; then
|
||||
ok "RTL-SDR kernel driver blacklist already present"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Blacklisting conflicting DVB kernel drivers..."
|
||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||
else
|
||||
info "Blacklisting conflicting DVB kernel drivers..."
|
||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
||||
blacklist dvb_usb_rtl28xxu
|
||||
blacklist rtl2832
|
||||
blacklist rtl2830
|
||||
blacklist r820t
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Unload modules if currently loaded
|
||||
# Always unload modules if currently loaded — this must happen even on
|
||||
# re-runs where the blacklist file already exists, since the modules may
|
||||
# still be live from the current boot (e.g. blacklist wasn't in initramfs).
|
||||
local unloaded=false
|
||||
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
||||
if lsmod | grep -q "^$mod"; then
|
||||
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
||||
unloaded=true
|
||||
fi
|
||||
done
|
||||
$unloaded && info "Unloaded conflicting DVB kernel modules from current session."
|
||||
|
||||
# Bake the blacklist into the initramfs so it survives reboots on
|
||||
# Raspberry Pi OS / Debian (without this the modules can reload on boot).
|
||||
if cmd_exists update-initramfs; then
|
||||
info "Updating initramfs to persist driver blacklist across reboots..."
|
||||
$SUDO update-initramfs -u >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
||||
echo
|
||||
@@ -849,7 +1303,7 @@ install_debian_packages() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=21
|
||||
TOTAL_STEPS=27
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
@@ -891,12 +1345,18 @@ install_debian_packages() {
|
||||
|
||||
apt_install_if_missing rtl-sdr
|
||||
|
||||
progress "RTL-SDR Blog drivers"
|
||||
if cmd_exists rtl_test; then
|
||||
ok "RTL-SDR drivers already installed"
|
||||
progress "RTL-SDR Blog drivers (V4 support)"
|
||||
if $IS_DRAGONOS; then
|
||||
info "DragonOS: skipping RTL-SDR Blog driver install (pre-configured)."
|
||||
else
|
||||
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
echo
|
||||
info "RTL-SDR Blog drivers add V4 (R828D tuner) support and bias-tee improvements."
|
||||
info "They are backward-compatible with all RTL-SDR devices."
|
||||
if ask_yes_no "Install RTL-SDR Blog drivers? (recommended for V4 users, safe for all)" "y"; then
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
else
|
||||
warn "Skipping RTL-SDR Blog drivers. V4 devices may not work correctly."
|
||||
fi
|
||||
fi
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
@@ -905,8 +1365,8 @@ install_debian_packages() {
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
apt_install direwolf || true
|
||||
|
||||
progress "Installing slowrx (SSTV decoder)"
|
||||
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian
|
||||
progress "SSTV decoder"
|
||||
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
apt_install ffmpeg
|
||||
@@ -914,6 +1374,9 @@ install_debian_packages() {
|
||||
progress "Installing rtl_433"
|
||||
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
||||
|
||||
progress "Installing HackRF tools"
|
||||
apt_install hackrf || warn "hackrf tools not available"
|
||||
|
||||
progress "Installing rtlamr (optional)"
|
||||
# rtlamr is optional - used for utility meter monitoring
|
||||
if ! cmd_exists rtlamr; then
|
||||
@@ -970,12 +1433,21 @@ install_debian_packages() {
|
||||
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
||||
|
||||
progress "Installing dump1090"
|
||||
# Remove any stale symlink left from a previous run where dump1090-mutability
|
||||
# was later uninstalled — cmd_exists finds the broken symlink and skips the
|
||||
# real install, leaving dump1090 seemingly present but non-functional.
|
||||
local dump1090_path
|
||||
dump1090_path="$(command -v dump1090 2>/dev/null || true)"
|
||||
if [[ -n "$dump1090_path" ]] && [[ ! -x "$dump1090_path" ]]; then
|
||||
info "Removing broken dump1090 symlink: $dump1090_path"
|
||||
$SUDO rm -f "$dump1090_path"
|
||||
fi
|
||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||
fi
|
||||
if ! cmd_exists dump1090; then
|
||||
if cmd_exists dump1090-mutability; then
|
||||
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
||||
$SUDO ln -s "$(which dump1090-mutability)" /usr/local/sbin/dump1090
|
||||
fi
|
||||
fi
|
||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||
@@ -986,6 +1458,13 @@ install_debian_packages() {
|
||||
fi
|
||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||
|
||||
progress "Installing dumpvdl2"
|
||||
if ! cmd_exists dumpvdl2; then
|
||||
install_dumpvdl2_from_source_debian || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||
else
|
||||
ok "dumpvdl2 already installed"
|
||||
fi
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
install_aiscatcher_from_source_debian
|
||||
@@ -993,6 +1472,19 @@ install_debian_packages() {
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
|
||||
progress "Installing SatDump (optional)"
|
||||
if ! cmd_exists satdump; then
|
||||
echo
|
||||
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||
if ask_yes_no "Do you want to install SatDump?"; then
|
||||
install_satdump_from_source_debian || warn "SatDump build failed. Weather satellite decoding will not be available."
|
||||
else
|
||||
warn "Skipping SatDump installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "SatDump already installed"
|
||||
fi
|
||||
|
||||
progress "Configuring udev rules"
|
||||
setup_udev_rules_debian
|
||||
|
||||
@@ -1042,6 +1534,14 @@ final_summary_and_hard_fail() {
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${#missing_recommended[@]}" -gt 0 ]]; then
|
||||
echo
|
||||
warn "Missing RECOMMENDED tools (some features will not work):"
|
||||
for t in "${missing_recommended[@]}"; do echo " - $t"; done
|
||||
echo
|
||||
warn "Install these for full functionality"
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
@@ -1088,8 +1588,24 @@ main() {
|
||||
fi
|
||||
|
||||
install_python_deps
|
||||
|
||||
# Download leaflet-heat plugin (offline mode)
|
||||
if [ ! -f "static/vendor/leaflet-heat/leaflet-heat.js" ]; then
|
||||
info "Downloading leaflet-heat plugin..."
|
||||
mkdir -p static/vendor/leaflet-heat
|
||||
if curl -sL "https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js" \
|
||||
-o static/vendor/leaflet-heat/leaflet-heat.js; then
|
||||
ok "leaflet-heat plugin downloaded"
|
||||
else
|
||||
warn "Failed to download leaflet-heat plugin. Heatmap will use CDN."
|
||||
fi
|
||||
fi
|
||||
|
||||
final_summary_and_hard_fail
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
# Clear traps before exiting to prevent spurious errors during cleanup
|
||||
trap - ERR EXIT
|
||||
exit 0
|
||||
|
||||
@@ -5,38 +5,42 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #141a24;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: rgba(74, 158, 255, 0.6);
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: rgba(74, 163, 255, 0.4);
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--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='%23ffffff'%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");
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.radar-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -48,10 +52,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -72,6 +78,18 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
@@ -91,7 +109,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -268,7 +286,7 @@ body {
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
@@ -306,7 +324,7 @@ body {
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -347,7 +365,7 @@ body {
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.empty-row td,
|
||||
|
||||
@@ -1,343 +1,331 @@
|
||||
/*
|
||||
* Agents Management CSS
|
||||
* Styles for the remote agent management interface
|
||||
*/
|
||||
|
||||
/* CSS Variables (inherited from main theme) */
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #888;
|
||||
--border-color: #1a1a2e;
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-green: #00ff88;
|
||||
--accent-red: #ff3366;
|
||||
--accent-orange: #ff9f1c;
|
||||
}
|
||||
|
||||
/* Agent indicator in navigation */
|
||||
.agent-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-indicator:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.remote {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 6px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.multiple {
|
||||
background: var(--accent-orange);
|
||||
box-shadow: 0 0 6px var(--accent-orange);
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.agent-indicator-count {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Agent selector dropdown */
|
||||
.agent-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-header h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.agent-selector-manage {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agent-selector-manage:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.agent-selector-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-selector-item:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.agent-selector-item.selected {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-selector-item.local {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-status.online {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status.offline {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-url {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-check {
|
||||
color: var(--accent-green);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item.selected .agent-selector-item-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Agent badge in data displays */
|
||||
.agent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.agent-badge.local,
|
||||
.agent-badge.agent-local {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-badge.agent-remote {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* WiFi table agent column */
|
||||
.wifi-networks-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wifi-networks-table th.col-agent {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Bluetooth table agent column */
|
||||
.bt-devices-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Agent column in data tables */
|
||||
.data-table .agent-col {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Multi-agent stream indicator */
|
||||
.multi-agent-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.multi-agent-indicator.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.multi-agent-indicator-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* Agent connection status toast */
|
||||
.agent-toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
padding: 10px 15px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-toast.connected {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-toast.disconnected {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.agent-indicator {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.agents-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Agents Management CSS
|
||||
* Styles for the remote agent management interface
|
||||
* Inherits CSS variables from core/variables.css
|
||||
*/
|
||||
|
||||
/* Agent indicator in navigation */
|
||||
.agent-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-indicator:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.remote {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 6px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.multiple {
|
||||
background: var(--accent-orange);
|
||||
box-shadow: 0 0 6px var(--accent-orange);
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-indicator-count {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Agent selector dropdown */
|
||||
.agent-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-header h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.agent-selector-manage {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agent-selector-manage:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.agent-selector-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-selector-item:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.agent-selector-item.selected {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-selector-item.local {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-status.online {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status.offline {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-url {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-check {
|
||||
color: var(--accent-green);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item.selected .agent-selector-item-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Agent badge in data displays */
|
||||
.agent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-badge.local,
|
||||
.agent-badge.agent-local {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-badge.agent-remote {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* WiFi table agent column */
|
||||
.wifi-networks-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wifi-networks-table th.col-agent {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Bluetooth table agent column */
|
||||
.bt-devices-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Agent column in data tables */
|
||||
.data-table .agent-col {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Multi-agent stream indicator */
|
||||
.multi-agent-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.multi-agent-indicator.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.multi-agent-indicator-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* Agent connection status toast */
|
||||
.agent-toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
padding: 10px 15px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-toast.connected {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-toast.disconnected {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.agent-indicator {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.agents-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,51 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-green: #22c55e;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--radar-cyan: #4a9eff;
|
||||
--radar-bg: #0f1218;
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: #4aa3ff;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-green: #38c180;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-red: #e25d5d;
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--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='%23ffffff'%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: #4aa3ff;
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-dark: #f4f7fb;
|
||||
--bg-panel: #e9eef5;
|
||||
--bg-card: #ffffff;
|
||||
--border-color: #d1d9e6;
|
||||
--border-glow: #1f5fa8;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-orange: #b5863a;
|
||||
--accent-red: #c74444;
|
||||
--accent-yellow: #b5863a;
|
||||
--accent-amber: #b5863a;
|
||||
--grid-line: rgba(31, 95, 168, 0.12);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #1f5fa8;
|
||||
--radar-bg: #e9eef5;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
@@ -43,9 +67,10 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -58,10 +83,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -89,6 +116,18 @@ body {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: 12px 20px;
|
||||
@@ -97,7 +136,7 @@ body {
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
@@ -132,10 +171,49 @@ body {
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.agent-selector-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-select-sm {
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-status-dot.offline {
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 6px var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-compact .show-all-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
@@ -183,7 +261,7 @@ body {
|
||||
}
|
||||
|
||||
.strip-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
@@ -287,7 +365,7 @@ body {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||
white-space: nowrap;
|
||||
@@ -314,20 +392,21 @@ body {
|
||||
}
|
||||
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Main dashboard grid - Mobile first */
|
||||
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
|
||||
.dashboard {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: calc(100dvh - 95px);
|
||||
height: calc(100vh - 95px);
|
||||
height: calc(100dvh - 160px);
|
||||
height: calc(100vh - 160px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@@ -367,7 +446,7 @@ body {
|
||||
/* Leaflet overrides - Dark map styling */
|
||||
.leaflet-container {
|
||||
background: var(--bg-dark) !important;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Using actual dark tiles now - no filter needed */
|
||||
@@ -438,7 +517,7 @@ body {
|
||||
padding: 10px 15px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
@@ -510,7 +589,7 @@ body {
|
||||
}
|
||||
|
||||
.vessel-name {
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
@@ -518,7 +597,7 @@ body {
|
||||
}
|
||||
|
||||
.vessel-mmsi {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
@@ -548,7 +627,7 @@ body {
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -604,20 +683,20 @@ body {
|
||||
}
|
||||
|
||||
.vessel-item-name {
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.vessel-item-type {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vessel-item-speed {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-align: right;
|
||||
@@ -628,14 +707,31 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.03);
|
||||
border: 1px solid rgba(74, 158, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group > .control-group-items {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
@@ -687,7 +783,7 @@ body {
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -698,7 +794,7 @@ body {
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -717,7 +813,7 @@ body {
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -834,7 +930,7 @@ body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
min-height: calc(100dvh - 95px);
|
||||
min-height: calc(100dvh - 160px);
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -1004,7 +1100,7 @@ body {
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -1079,7 +1175,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-message-category {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -1096,13 +1192,13 @@ body {
|
||||
}
|
||||
|
||||
.dsc-message-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.dsc-message-mmsi {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
@@ -1120,7 +1216,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-message-pos {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -1148,7 +1244,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-distress-alert .dsc-alert-header {
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-red);
|
||||
@@ -1157,7 +1253,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-distress-alert .dsc-alert-mmsi {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 8px;
|
||||
@@ -1177,7 +1273,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-distress-alert .dsc-alert-position {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 16px;
|
||||
@@ -1188,7 +1284,7 @@ body {
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 24px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/* Function Strip (Action Bar) - Shared across modes
|
||||
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
|
||||
*/
|
||||
|
||||
.function-strip {
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.function-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Strip title badge */
|
||||
.function-strip .strip-title {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.function-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
.function-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.function-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.function-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Signal stat coloring */
|
||||
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.function-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.function-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.function-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.function-strip .strip-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.function-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.function-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.function-strip .strip-input:hover,
|
||||
.function-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.function-strip .strip-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Wider input for frequency values */
|
||||
.function-strip .strip-input.wide {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.function-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.function-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-tool.warn {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
color: var(--accent-yellow);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.function-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.function-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);
|
||||
}
|
||||
|
||||
.function-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.function-strip .status-dot.inactive {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.function-strip .status-dot.active,
|
||||
.function-strip .status-dot.scanning,
|
||||
.function-strip .status-dot.decoding {
|
||||
background: var(--accent-cyan);
|
||||
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.listening,
|
||||
.function-strip .status-dot.tracking,
|
||||
.function-strip .status-dot.receiving {
|
||||
background: var(--accent-green);
|
||||
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.sweeping {
|
||||
background: var(--accent-orange);
|
||||
animation: strip-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.function-strip .strip-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mode-specific accent colors */
|
||||
.function-strip.pager-strip .strip-stat {
|
||||
background: rgba(255, 193, 7, 0.05);
|
||||
border-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
.function-strip.pager-strip .strip-stat:hover {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
.function-strip.pager-strip .strip-value {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.function-strip.sensor-strip .strip-stat {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-color: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
.function-strip.sensor-strip .strip-stat:hover {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
.function-strip.sensor-strip .strip-value {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.function-strip.bt-strip .strip-stat {
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
border-color: rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
.function-strip.bt-strip .strip-stat:hover {
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
border-color: rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
.function-strip.bt-strip .strip-value {
|
||||
color: #0a84ff;
|
||||
}
|
||||
|
||||
.function-strip.wifi-strip .strip-stat {
|
||||
background: rgba(255, 149, 0, 0.05);
|
||||
border-color: rgba(255, 149, 0, 0.15);
|
||||
}
|
||||
.function-strip.wifi-strip .strip-stat:hover {
|
||||
background: rgba(255, 149, 0, 0.1);
|
||||
border-color: rgba(255, 149, 0, 0.3);
|
||||
}
|
||||
.function-strip.wifi-strip .strip-value {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.function-strip.tscm-strip {
|
||||
margin-top: 4px; /* Extra clearance to prevent top clipping */
|
||||
}
|
||||
|
||||
.function-strip.tscm-strip .strip-stat {
|
||||
background: rgba(255, 59, 48, 0.15);
|
||||
border: 1px solid rgba(255, 59, 48, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-stat:hover {
|
||||
background: rgba(255, 59, 48, 0.25);
|
||||
border-color: rgba(255, 59, 48, 0.6);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-value {
|
||||
color: #ef4444; /* Explicit red color */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-label {
|
||||
color: #9ca3af; /* Explicit light gray */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-select {
|
||||
color: #e8eaed; /* Explicit white for selects */
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-btn {
|
||||
color: #e8eaed; /* Explicit white for buttons */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-tool {
|
||||
color: #e8eaed; /* Explicit white for tool indicators */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-time,
|
||||
.function-strip.tscm-strip .strip-status span {
|
||||
color: #9ca3af; /* Explicit gray for status/time */
|
||||
}
|
||||
|
||||
.function-strip.rtlamr-strip .strip-stat {
|
||||
background: rgba(175, 82, 222, 0.05);
|
||||
border-color: rgba(175, 82, 222, 0.15);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-stat:hover {
|
||||
background: rgba(175, 82, 222, 0.1);
|
||||
border-color: rgba(175, 82, 222, 0.3);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-value {
|
||||
color: #af52de;
|
||||
}
|
||||
|
||||
.function-strip.listening-strip .strip-stat {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border-color: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
.function-strip.listening-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.function-strip.listening-strip .strip-value {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Threat-colored stats for TSCM */
|
||||
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
|
||||
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
|
||||
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
|
||||
@@ -13,11 +13,17 @@
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover {
|
||||
transform: scale(1.3);
|
||||
.radar-device:hover .radar-dot {
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
.radar-device-hitarea {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/* 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: #fff;
|
||||
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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* INTERCEPT Base Styles
|
||||
* Reset, typography, and foundational element styles
|
||||
* Requires: variables.css to be imported first
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS RESET
|
||||
============================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
||||
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
h4 { font-size: var(--text-xl); }
|
||||
h5 { font-size: var(--text-lg); }
|
||||
h6 { font-size: var(--text-base); }
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-cyan-hover);
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
strong, b {
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
code, kbd, pre, samp {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FORM ELEMENTS
|
||||
============================================ */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TABLES
|
||||
============================================ */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LISTS
|
||||
============================================ */
|
||||
ul, ol {
|
||||
padding-left: var(--space-6);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
/* Text colors */
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-cyan { color: var(--accent-cyan); }
|
||||
.text-green { color: var(--accent-green); }
|
||||
.text-red { color: var(--accent-red); }
|
||||
.text-orange { color: var(--accent-orange); }
|
||||
.text-amber { color: var(--accent-amber); }
|
||||
|
||||
/* Font utilities */
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
|
||||
/* Text sizes */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
|
||||
/* Display */
|
||||
.hidden { display: none !important; }
|
||||
.block { display: block; }
|
||||
.inline-block { display: inline-block; }
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.grid { display: grid; }
|
||||
|
||||
/* Flexbox */
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.flex-1 { flex: 1; }
|
||||
.gap-1 { gap: var(--space-1); }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
|
||||
/* Spacing */
|
||||
.m-0 { margin: 0; }
|
||||
.mt-2 { margin-top: var(--space-2); }
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.p-2 { padding: var(--space-2); }
|
||||
.p-3 { padding: var(--space-3); }
|
||||
.p-4 { padding: var(--space-4); }
|
||||
|
||||
/* Borders */
|
||||
.rounded { border-radius: var(--radius-md); }
|
||||
.rounded-lg { border-radius: var(--radius-lg); }
|
||||
.border { border: 1px solid var(--border-color); }
|
||||
|
||||
/* Truncate text */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Screen reader only */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SCROLLBAR STYLING
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-light);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-light) var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SELECTION
|
||||
============================================ */
|
||||
::selection {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UX POLISH - TRANSITIONS & INTERACTIONS
|
||||
============================================ */
|
||||
|
||||
/* Smooth page transitions */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Better focus ring for all interactive elements */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Remove focus ring for mouse users */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Active state feedback */
|
||||
button:active:not(:disabled),
|
||||
a:active,
|
||||
[role="button"]:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[role="button"] {
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Subtle hover lift effect for cards and panels */
|
||||
.card:hover,
|
||||
.panel:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Link underline on hover */
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip link for accessibility */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
z-index: 9999;
|
||||
transition: top var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-color: #4b5563;
|
||||
--text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
/**
|
||||
* INTERCEPT UI Components
|
||||
* Reusable component styles for buttons, cards, badges, etc.
|
||||
* Requires: variables.css and base.css
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
BUTTONS
|
||||
============================================ */
|
||||
|
||||
/* Base button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn-primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-cyan-hover);
|
||||
border-color: var(--accent-cyan-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
/* Button sizes */
|
||||
.btn-sm {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.btn-icon {
|
||||
padding: var(--space-2);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.btn-icon.btn-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CARDS / PANELS
|
||||
============================================ */
|
||||
.card {
|
||||
background: var(--surface-panel-gradient);
|
||||
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Panel variant (used in dashboards) */
|
||||
.panel {
|
||||
background: var(--surface-panel-gradient);
|
||||
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
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)) {
|
||||
.card,
|
||||
.panel {
|
||||
--notch-size: 6px;
|
||||
border-radius: 0;
|
||||
clip-path: polygon(
|
||||
var(--notch-size) 0,
|
||||
calc(100% - var(--notch-size)) 0,
|
||||
100% var(--notch-size),
|
||||
100% calc(100% - var(--notch-size)),
|
||||
calc(100% - var(--notch-size)) 100%,
|
||||
var(--notch-size) 100%,
|
||||
0 calc(100% - var(--notch-size)),
|
||||
0 var(--notch-size)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header::before,
|
||||
.panel-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--space-3);
|
||||
width: 36px;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.panel-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--status-offline);
|
||||
}
|
||||
|
||||
.panel-indicator.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 8px var(--status-online);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BADGES
|
||||
============================================ */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--accent-green-dim);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--accent-orange-dim);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--accent-red-dim);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DATA TAGS
|
||||
============================================ */
|
||||
.data-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-glow);
|
||||
}
|
||||
|
||||
.data-tag--accent {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.data-tag--warning {
|
||||
border-color: var(--accent-amber);
|
||||
color: var(--accent-amber);
|
||||
background: var(--accent-amber-dim);
|
||||
}
|
||||
|
||||
.data-tag--success {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
background: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.data-tag--danger {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
background: var(--accent-red-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATUS INDICATORS
|
||||
============================================ */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--status-offline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.online,
|
||||
.status-dot.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 4px var(--status-online);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: var(--status-warning);
|
||||
box-shadow: 0 0 4px var(--status-warning);
|
||||
}
|
||||
|
||||
.status-dot.error,
|
||||
.status-dot.offline {
|
||||
background: var(--status-error);
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Pulse animation for active status */
|
||||
.status-dot.pulse {
|
||||
animation: statusPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--space-4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-dim);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.empty-state-action {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOADING STATES
|
||||
============================================ */
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Loading overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-overlay);
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 25%,
|
||||
var(--bg-elevated) 50%,
|
||||
var(--bg-tertiary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS STRIP
|
||||
============================================ */
|
||||
.stats-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 var(--space-4);
|
||||
height: var(--stats-strip-height);
|
||||
overflow-x: auto;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.strip-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.strip-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color);
|
||||
margin: 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FORM GROUPS
|
||||
============================================ */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Inline checkbox/radio */
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-check input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.form-check label {
|
||||
margin-bottom: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERTS / TOASTS
|
||||
============================================ */
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--accent-green-dim);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: var(--accent-orange-dim);
|
||||
border-color: var(--accent-orange);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: var(--accent-red-dim);
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOOLTIPS
|
||||
============================================ */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||
z-index: var(--z-tooltip);
|
||||
pointer-events: none;
|
||||
margin-bottom: var(--space-1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ICONS
|
||||
============================================ */
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon--sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.icon--lg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SECTION HEADERS
|
||||
============================================ */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--accent-cyan);
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 6px 0 var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DIVIDERS
|
||||
============================================ */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--border-light),
|
||||
var(--border-light) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.divider-vertical {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
var(--border-light),
|
||||
var(--border-light) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
margin: 0 var(--space-3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UX POLISH - ENHANCED INTERACTIONS
|
||||
============================================ */
|
||||
|
||||
/* Button hover lift */
|
||||
.btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 0 1px var(--border-light);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
box-shadow: inset 0 0 0 1px var(--border-light);
|
||||
}
|
||||
|
||||
/* Card/Panel hover effects */
|
||||
.card,
|
||||
.panel {
|
||||
transition:
|
||||
box-shadow var(--transition-base),
|
||||
border-color var(--transition-base),
|
||||
transform var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.panel:hover {
|
||||
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 */
|
||||
.strip-stat {
|
||||
transition: background-color var(--transition-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.strip-stat:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Status dot pulse animation */
|
||||
.status-dot.online,
|
||||
.status-dot.active {
|
||||
animation: statusGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 4px var(--status-online);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 8px var(--status-online), 0 0 12px var(--status-online);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge hover effect */
|
||||
.badge {
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Alert entrance animation */
|
||||
.alert {
|
||||
animation: alertSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes alertSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinner smooth appearance */
|
||||
.spinner {
|
||||
animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Input focus glow */
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
/* Nav item active indicator */
|
||||
.nav-item,
|
||||
.mode-nav-btn,
|
||||
.mobile-nav-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item.active::after,
|
||||
.mode-nav-btn.active::after,
|
||||
.mobile-nav-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12%;
|
||||
right: 12%;
|
||||
bottom: 2px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.75;
|
||||
animation: railPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes railPulse {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Smooth tooltip appearance */
|
||||
[data-tooltip]::after {
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
transform: translateX(-50%) translateY(-4px);
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* Disabled state with better visual feedback */
|
||||
:disabled,
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(30%);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* INTERCEPT Design Tokens
|
||||
* Single source of truth for colors, spacing, typography, and effects
|
||||
* Import this file FIRST in any stylesheet that needs design tokens
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ============================================
|
||||
COLOR PALETTE - Dark Theme (Default)
|
||||
============================================ */
|
||||
|
||||
/* Backgrounds - layered depth system */
|
||||
--bg-primary: #0b1118;
|
||||
--bg-secondary: #101823;
|
||||
--bg-tertiary: #151f2b;
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||
--surface-glass: rgba(16, 25, 37, 0.82);
|
||||
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||
--ambient-top-left: rgba(74, 163, 255, 0.14);
|
||||
--ambient-top-right: rgba(56, 193, 128, 0.09);
|
||||
--ambient-bottom: rgba(214, 168, 94, 0.06);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
|
||||
/* Accent colors */
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||
--accent-cyan-hover: #6bb3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||
--accent-red: #e25d5d;
|
||||
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-orange-dim: rgba(214, 168, 94, 0.16);
|
||||
--accent-amber: #d6a85e;
|
||||
--accent-amber-dim: rgba(214, 168, 94, 0.18);
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-purple: #8f7bd6;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--text-muted: #445266;
|
||||
--text-inverse: #0b1118;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #263246;
|
||||
--border-light: #354458;
|
||||
--border-glow: rgba(74, 163, 255, 0.25);
|
||||
--border-focus: var(--accent-cyan);
|
||||
|
||||
/* Status colors */
|
||||
--status-online: #38c180;
|
||||
--status-warning: #d6a85e;
|
||||
--status-error: #e25d5d;
|
||||
--status-offline: #6f7f94;
|
||||
--status-info: #4aa3ff;
|
||||
|
||||
/* Subtle grid/pattern */
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--grid-dot: rgba(255, 255, 255, 0.03);
|
||||
--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='%23ffffff'%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");
|
||||
|
||||
/* ============================================
|
||||
SPACING SCALE
|
||||
============================================ */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 10px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 20px;
|
||||
--text-3xl: 24px;
|
||||
--text-4xl: 30px;
|
||||
|
||||
/* Font weights */
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* Line heights */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* ============================================
|
||||
BORDERS & RADIUS
|
||||
============================================ */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 8px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ============================================
|
||||
SHADOWS
|
||||
============================================ */
|
||||
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
|
||||
--shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
|
||||
|
||||
/* ============================================
|
||||
TRANSITIONS
|
||||
============================================ */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* ============================================
|
||||
Z-INDEX SCALE
|
||||
============================================ */
|
||||
--z-base: 0;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-fixed: 300;
|
||||
--z-modal-backdrop: 400;
|
||||
--z-modal: 500;
|
||||
--z-toast: 600;
|
||||
--z-tooltip: 700;
|
||||
|
||||
/* ============================================
|
||||
LAYOUT
|
||||
============================================ */
|
||||
--header-height: 60px;
|
||||
--nav-height: 44px;
|
||||
--sidebar-width: 280px;
|
||||
--stats-strip-height: 36px;
|
||||
--content-max-width: 1400px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIGHT THEME OVERRIDES
|
||||
============================================ */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f4f7fb;
|
||||
--bg-secondary: #e9eef5;
|
||||
--bg-tertiary: #dde5f0;
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #f1f4f9;
|
||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||
--surface-glass: rgba(255, 255, 255, 0.84);
|
||||
--surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%);
|
||||
--ambient-top-left: rgba(31, 95, 168, 0.1);
|
||||
--ambient-top-right: rgba(31, 138, 87, 0.06);
|
||||
--ambient-bottom: rgba(181, 134, 58, 0.05);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||
--accent-cyan-hover: #2c73bf;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-green-dim: rgba(31, 138, 87, 0.12);
|
||||
--accent-red: #c74444;
|
||||
--accent-red-dim: rgba(199, 68, 68, 0.12);
|
||||
--accent-orange: #b5863a;
|
||||
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||
--accent-amber: #b5863a;
|
||||
--accent-amber-dim: rgba(181, 134, 58, 0.12);
|
||||
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--text-muted: #aab6c8;
|
||||
--text-inverse: #f4f7fb;
|
||||
|
||||
--border-color: #d1d9e6;
|
||||
--border-light: #c1ccdb;
|
||||
--border-glow: rgba(31, 95, 168, 0.12);
|
||||
|
||||
--grid-line: rgba(31, 95, 168, 0.14);
|
||||
--grid-dot: rgba(12, 18, 24, 0.06);
|
||||
--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");
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||
--shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REDUCED MOTION
|
||||
============================================ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-fast: 0ms;
|
||||
--transition-base: 0ms;
|
||||
--transition-slow: 0ms;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,20 @@
|
||||
/* Local font declarations for offline mode */
|
||||
|
||||
/* Inter - Primary UI font */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* JetBrains Mono - Monospace/code font */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||
}
|
||||
/* Local font declarations for offline mode */
|
||||
/* Roboto Condensed - variable font, one file covers all weights */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/RobotoCondensed-Latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/RobotoCondensed-LatinExt.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/* ============================================
|
||||
Global Navigation Styles
|
||||
Shared across all pages using nav.html
|
||||
============================================ */
|
||||
|
||||
/* Icon base (kept lightweight for nav usage) */
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon--sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Mode Navigation Bar */
|
||||
.mode-nav {
|
||||
display: none;
|
||||
background: linear-gradient(180deg, rgba(17, 22, 32, 0.92), rgba(15, 20, 28, 0.88));
|
||||
border-bottom: 1px solid var(--border-color, #202833);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 1100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mode-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-nav-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.mode-nav-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color, #202833);
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.mode-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mode-nav-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mode-nav-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.8);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.mode-nav-btn.active {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-btn.active .nav-icon {
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(24, 31, 44, 0.85);
|
||||
border: 1px solid var(--border-light, #2b3645);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-action-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nav-action-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.95);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
box-shadow: 0 8px 16px rgba(5, 9, 15, 0.35);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
/* Dropdown Navigation */
|
||||
.mode-nav-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .nav-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn .dropdown-arrow svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.8);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 180px;
|
||||
background: rgba(16, 22, 32, 0.98);
|
||||
border: 1px solid var(--border-color, #202833);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 36px rgba(5, 9, 15, 0.55);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1000;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.85);
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: rgba(27, 36, 51, 0.95);
|
||||
color: var(--text-primary, #e7ebf2);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan, #4d7dbf);
|
||||
}
|
||||
|
||||
/* Nav Bar Utilities */
|
||||
.nav-utilities {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.nav-utilities {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-clock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-clock .utc-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #8a97a8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.nav-clock .utc-time {
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color, #202833);
|
||||
}
|
||||
|
||||
.nav-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-tool-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(20, 33, 53, 0.6);
|
||||
border: 1px solid rgba(77, 125, 191, 0.12);
|
||||
color: var(--text-secondary, #b7c1cf);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tool-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.9);
|
||||
border-color: var(--accent-cyan, #4d7dbf);
|
||||
color: var(--accent-cyan, #4d7dbf);
|
||||
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||
}
|
||||
|
||||
/* Position relative needed for absolute positioned icon children */
|
||||
.nav-tool-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-nav-btn:focus-visible,
|
||||
.mode-nav-dropdown-btn:focus-visible,
|
||||
.nav-action-btn:focus-visible,
|
||||
.nav-tool-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan, #4d7dbf);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Nav tool button SVG sizing and styling */
|
||||
.nav-tool-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Theme toggle icon states */
|
||||
.nav-tool-btn .icon-sun,
|
||||
.nav-tool-btn .icon-moon {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-sun {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-moon {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Effects/animations toggle icon states */
|
||||
.nav-tool-btn .icon-effects-off {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Main Dashboard Button in Nav */
|
||||
a.nav-dashboard-btn,
|
||||
a.nav-dashboard-btn:link,
|
||||
a.nav-dashboard-btn:visited {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(20, 33, 53, 0.6) !important;
|
||||
border: 1px solid rgba(77, 125, 191, 0.12) !important;
|
||||
color: #b7c1cf !important;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
a.nav-dashboard-btn:hover {
|
||||
background: rgba(27, 36, 51, 0.9) !important;
|
||||
border-color: #4d7dbf !important;
|
||||
color: #4d7dbf !important;
|
||||
box-shadow: 0 6px 14px rgba(5, 9, 15, 0.35);
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .nav-label {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Help Modal Styles
|
||||
* Shared across all pages that include the help modal partial
|
||||
*/
|
||||
|
||||
.help-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 10000;
|
||||
overflow-y: auto;
|
||||
padding: 40px 20px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
}
|
||||
|
||||
.help-modal.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-content h2 {
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
margin-bottom: 16px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-content h3 {
|
||||
color: var(--text-primary, #e8eaed);
|
||||
margin: 20px 0 10px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||
padding-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim, #4b5563);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.help-close:hover {
|
||||
color: var(--accent-red, #ef4444);
|
||||
}
|
||||
|
||||
.help-modal .icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.help-modal .icon-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-primary, #0a0c10);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.help-modal .icon-item .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.help-modal .icon-item .icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.help-modal .icon-item .desc {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.help-modal .tip-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.help-modal .tip-list li {
|
||||
padding: 5px 0;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||
}
|
||||
|
||||
.help-modal .tip-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.help-modal .tip-list li::before {
|
||||
content: '\203A';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.help-modal .tip-list li strong {
|
||||
color: var(--text-primary, #e8eaed);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.help-tab {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: var(--bg-primary, #0a0c10);
|
||||
border: none;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-tab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #1f2937);
|
||||
}
|
||||
|
||||
.help-tab:hover {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
color: var(--text-primary, #e8eaed);
|
||||
}
|
||||
|
||||
.help-tab.active {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.help-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.help-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.help-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ensure code tags are styled */
|
||||
.help-modal code {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
font-size: 10.5px;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
/* Typography */
|
||||
.landing-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4em;
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
|
||||
.landing-tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.15em;
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
/* Hacker Style Error */
|
||||
.flash-error {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent-red);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
@@ -94,7 +94,7 @@
|
||||
color: var(--accent-cyan);
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
box-sizing: border-box; /* Crucial for visibility */
|
||||
@@ -106,7 +106,7 @@
|
||||
border: 2px solid var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
padding: 15px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
letter-spacing: 3px;
|
||||
cursor: pointer;
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
.landing-version {
|
||||
margin-top: 25px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
letter-spacing: 2px;
|
||||
|
||||
@@ -1,328 +1,375 @@
|
||||
/* APRS Function Bar (Stats Strip) Styles */
|
||||
.aprs-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: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.aprs-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
.aprs-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
.aprs-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.aprs-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.aprs-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
/* Signal stat coloring */
|
||||
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.aprs-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.aprs-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.aprs-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.aprs-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.aprs-strip .strip-input:hover,
|
||||
.aprs-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.aprs-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.aprs-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aprs-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
.aprs-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.aprs-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);
|
||||
}
|
||||
.aprs-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-strip .status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.tracking {
|
||||
background: var(--accent-green);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
@keyframes aprs-strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.aprs-strip .strip-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* APRS Status Bar Styles (Sidebar - legacy) */
|
||||
.aprs-status-bar {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-status-dot.standby { background: var(--text-muted); }
|
||||
.aprs-status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-status-dot.tracking { background: var(--accent-green); }
|
||||
.aprs-status-dot.error { background: var(--accent-red); }
|
||||
@keyframes aprs-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
.aprs-status-text {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.aprs-status-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.aprs-stat {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-stat-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Signal Meter Styles */
|
||||
.aprs-signal-meter {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-meter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-meter-label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-meter-value {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--accent-cyan);
|
||||
min-width: 24px;
|
||||
}
|
||||
.aprs-meter-burst {
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-yellow);
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
animation: burst-flash 0.3s ease-out;
|
||||
}
|
||||
@keyframes burst-flash {
|
||||
0% { opacity: 1; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.aprs-meter-bar-container {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.aprs-meter-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg,
|
||||
var(--accent-green) 0%,
|
||||
var(--accent-cyan) 50%,
|
||||
var(--accent-yellow) 75%,
|
||||
var(--accent-red) 100%
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease-out;
|
||||
}
|
||||
.aprs-meter-bar.no-signal {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.aprs-meter-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.aprs-meter-status {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.aprs-meter-status.active {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.aprs-meter-status.no-signal {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
/* APRS Function Bar (Stats Strip) Styles */
|
||||
.aprs-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: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.aprs-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
.aprs-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
.aprs-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.aprs-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.aprs-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
/* Signal stat coloring */
|
||||
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.aprs-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.aprs-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.aprs-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.aprs-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.aprs-strip .strip-input:hover,
|
||||
.aprs-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.aprs-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.aprs-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aprs-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
.aprs-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.aprs-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);
|
||||
}
|
||||
.aprs-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-strip .status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.tracking {
|
||||
background: var(--accent-green);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
@keyframes aprs-strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.aprs-strip .strip-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* APRS Status Bar Styles (Sidebar - legacy) */
|
||||
.aprs-status-bar {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-status-dot.standby { background: var(--text-muted); }
|
||||
.aprs-status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-status-dot.tracking { background: var(--accent-green); }
|
||||
.aprs-status-dot.error { background: var(--accent-red); }
|
||||
@keyframes aprs-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
.aprs-status-text {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.aprs-status-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.aprs-stat {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-stat-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Signal Meter Styles */
|
||||
.aprs-signal-meter {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-meter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-meter-label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-meter-value {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--accent-cyan);
|
||||
min-width: 24px;
|
||||
}
|
||||
.aprs-meter-burst {
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-yellow);
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
animation: burst-flash 0.3s ease-out;
|
||||
}
|
||||
@keyframes burst-flash {
|
||||
0% { opacity: 1; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.aprs-meter-bar-container {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.aprs-meter-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg,
|
||||
var(--accent-green) 0%,
|
||||
var(--accent-cyan) 50%,
|
||||
var(--accent-yellow) 75%,
|
||||
var(--accent-red) 100%
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease-out;
|
||||
}
|
||||
.aprs-meter-bar.no-signal {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.aprs-meter-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.aprs-meter-status {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.aprs-meter-status.active {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.aprs-meter-status.no-signal {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
/* APRS map markers (flat SVG icons) */
|
||||
.aprs-map-marker-wrap {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.aprs-map-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 7px 2px 5px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(74, 158, 255, 0.35);
|
||||
background: rgba(10, 18, 28, 0.88);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.aprs-map-marker-icon {
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.aprs-map-marker-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.aprs-map-marker-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.aprs-map-marker.vehicle .aprs-map-marker-icon {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.aprs-map-marker.tower .aprs-map-marker-icon {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
/* BT Locate Mode Styles */
|
||||
|
||||
/* Environment preset grid */
|
||||
.btl-env-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btl-env-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btl-env-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btl-env-btn.active {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: var(--accent-green, #00ff88);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btl-env-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btl-env-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btl-env-n {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY HUD — main visuals area
|
||||
============================================ */
|
||||
|
||||
.btl-hud {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btl-hud-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.btl-hud-band {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 2px;
|
||||
padding: 14px 20px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
min-width: 130px;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-band.immediate {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
|
||||
animation: btl-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btl-hud-band.near {
|
||||
color: #f97316;
|
||||
border-color: #f97316;
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
box-shadow: 0 0 15px rgba(249, 115, 22, 0.15);
|
||||
animation: btl-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btl-hud-band.far {
|
||||
color: #eab308;
|
||||
border-color: #eab308;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
box-shadow: 0 0 10px rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
|
||||
@keyframes btl-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.btl-hud-metrics {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btl-hud-separator {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.btl-hud-metric-lg .btl-hud-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.btl-hud-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.btl-hud-unit {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.btl-hud-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btl-hud-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btl-hud-export-format {
|
||||
min-width: 62px;
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btl-hud-audio-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-hud-audio-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btl-hud-clear-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btl-hud-clear-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Bottom info bar */
|
||||
.btl-hud-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btl-hud-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btl-hud-info-item {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btl-hud-info-sep {
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.btl-hud-diag {
|
||||
display: none;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-hud-diag:not(:empty) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUALS AREA — map + chart
|
||||
============================================ */
|
||||
|
||||
.btl-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.btl-map-container {
|
||||
flex: 1;
|
||||
min-height: 250px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#btLocateMap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 450;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-heat-label {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btl-map-heat-bar {
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btl-map-heat-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 3px;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-rssi-chart-container {
|
||||
height: 100px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-rssi-chart-container .btl-chart-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#btLocateRssiChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOCATE BUTTON — Bluetooth device cards
|
||||
============================================ */
|
||||
|
||||
.bt-locate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent-green, #00ff88);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bt-locate-btn:hover {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
border-color: var(--accent-green, #00ff88);
|
||||
}
|
||||
|
||||
.bt-locate-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
IRK DETECT BUTTON + DEVICE PICKER
|
||||
============================================ */
|
||||
|
||||
.btl-detect-irk-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-detect-irk-btn:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.btl-detect-irk-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.btl-irk-picker {
|
||||
margin-top: 6px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btl-irk-picker-status {
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btl-irk-picker-list {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.btl-irk-picker-item {
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btl-irk-picker-item:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.btl-irk-picker-item:hover {
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
}
|
||||
|
||||
.btl-irk-picker-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btl-irk-picker-meta {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE — stack HUD vertically on narrow
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.btl-hud {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btl-hud-band {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.btl-hud-metrics {
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.btl-hud-controls {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/* GPS Mode Styles */
|
||||
|
||||
/* Sidebar info grid */
|
||||
.gps-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gps-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.gps-info-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-info-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gps-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Connection status */
|
||||
.gps-connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gps-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gps-status-dot.connected {
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-dot.waiting {
|
||||
background: #ffaa00;
|
||||
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-dot.error {
|
||||
background: #ff4444;
|
||||
box-shadow: 0 0 6px rgba(255, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Fix badge */
|
||||
.gps-fix-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-fix-badge.no-fix {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: #ff4444;
|
||||
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.gps-fix-badge.fix-2d {
|
||||
background: rgba(255, 170, 0, 0.2);
|
||||
color: #ffaa00;
|
||||
border: 1px solid rgba(255, 170, 0, 0.3);
|
||||
}
|
||||
|
||||
.gps-fix-badge.fix-3d {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* DOP quality indicators */
|
||||
.gps-dop-good { color: #00ff88; }
|
||||
.gps-dop-moderate { color: #ffaa00; }
|
||||
.gps-dop-poor { color: #ff4444; }
|
||||
|
||||
/* ===== Visuals Panel ===== */
|
||||
|
||||
.gps-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Top row: sky view + position info */
|
||||
.gps-visuals-top {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sky View */
|
||||
.gps-skyview-panel {
|
||||
flex: 1;
|
||||
min-width: 320px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.gps-skyview-panel h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: min(100%, 430px);
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.gps-sky-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-sky-label {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2px;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gps-sky-label-cardinal {
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat.unused {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.gps-sky-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
/* Position info panel */
|
||||
.gps-position-panel {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gps-position-panel h4 {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-pos-big {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gps-pos-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gps-pos-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gps-pos-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gps-pos-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Signal Strength Bars */
|
||||
.gps-signal-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.gps-signal-panel h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-signal-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 140px;
|
||||
padding: 0 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.gps-signal-bar-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 18px;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gps-signal-bar {
|
||||
width: 14px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.gps-signal-bar.unused {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.gps-signal-prn {
|
||||
font-size: 8px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
.gps-signal-snr {
|
||||
font-size: 7px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Constellation colors */
|
||||
.gps-const-gps { background-color: #00d4ff; }
|
||||
.gps-const-glonass { background-color: #00ff88; }
|
||||
.gps-const-galileo { background-color: #ff8800; }
|
||||
.gps-const-beidou { background-color: #ff4466; }
|
||||
.gps-const-sbas { background-color: #ffdd00; }
|
||||
.gps-const-qzss { background-color: #cc66ff; }
|
||||
|
||||
/* Legend */
|
||||
.gps-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gps-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.gps-legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.gps-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gps-empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.gps-empty-state p {
|
||||
font-size: 12px;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.gps-visuals-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gps-skyview-panel,
|
||||
.gps-position-panel {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.gps-pos-big {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
/* 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: #00ff88; }
|
||||
.sw-header-value.accent-yellow { color: #ffcc00; }
|
||||
.sw-header-value.accent-orange { color: #ff8800; }
|
||||
.sw-header-value.accent-red { color: #ff3366; }
|
||||
|
||||
/* 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: #00ff88; border-color: #00ff8833; }
|
||||
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
|
||||
.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; }
|
||||
.sw-scale-3 { color: #ff8800; border-color: #ff880033; }
|
||||
.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
|
||||
.sw-scale-5 { color: #ff0044; border-color: #ff004433; }
|
||||
|
||||
/* 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: #00ff88; background: #00ff8815; }
|
||||
.sw-band-fair { color: #ffcc00; background: #ffcc0015; }
|
||||
.sw-band-poor { color: #ff3366; 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;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
.spy-stations-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
|
||||
.spy-station-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
/* Type Badge */
|
||||
.spy-station-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -173,7 +173,7 @@
|
||||
}
|
||||
|
||||
.spy-meta-mode {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
@@ -186,7 +186,7 @@
|
||||
}
|
||||
|
||||
.spy-freq-list {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.6;
|
||||
@@ -199,7 +199,7 @@
|
||||
}
|
||||
|
||||
.spy-freq-item {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
background: var(--bg-secondary);
|
||||
@@ -236,7 +236,7 @@
|
||||
}
|
||||
|
||||
.spy-freq-select {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-secondary);
|
||||
@@ -273,7 +273,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -340,7 +340,9 @@
|
||||
MODE VISIBILITY - Ensure sidebar shows when active
|
||||
============================================ */
|
||||
#spystationsMode.active {
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
/**
|
||||
* SSTV General Mode Styles
|
||||
* Terrestrial Slow-Scan Television decoder interface
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
MODE VISIBILITY
|
||||
============================================ */
|
||||
#sstvGeneralMode.active {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUALS CONTAINER
|
||||
============================================ */
|
||||
.sstv-general-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS STRIP
|
||||
============================================ */
|
||||
.sstv-general-stats-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sstv-general-strip-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sstv-general-strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sstv-general-strip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sstv-general-strip-dot.idle {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.sstv-general-strip-dot.listening {
|
||||
background: var(--accent-yellow);
|
||||
animation: sstv-general-pulse 1s infinite;
|
||||
}
|
||||
|
||||
.sstv-general-strip-dot.decoding {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 6px var(--accent-cyan);
|
||||
animation: sstv-general-pulse 0.5s infinite;
|
||||
}
|
||||
|
||||
.sstv-general-strip-status-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sstv-general-strip-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sstv-general-strip-btn.start {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.sstv-general-strip-btn.start:hover {
|
||||
background: var(--accent-cyan-bright, #00d4ff);
|
||||
}
|
||||
|
||||
.sstv-general-strip-btn.stop {
|
||||
background: var(--accent-red, #ff3366);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sstv-general-strip-btn.stop:hover {
|
||||
background: #ff1a53;
|
||||
}
|
||||
|
||||
.sstv-general-strip-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.sstv-general-strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.sstv-general-strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sstv-general-strip-value.accent-cyan {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sstv-general-strip-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MAIN ROW (Live Decode + Gallery)
|
||||
============================================ */
|
||||
.sstv-general-main-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIVE DECODE SECTION
|
||||
============================================ */
|
||||
.sstv-general-live-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.sstv-general-live-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sstv-general-live-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sstv-general-live-title svg {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sstv-general-live-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sstv-general-canvas-container {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sstv-general-decode-info {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sstv-general-mode-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sstv-general-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sstv-general-progress-bar .progress {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sstv-general-status-message {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Idle state */
|
||||
.sstv-general-idle-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.sstv-general-idle-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sstv-general-idle-state h4 {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sstv-general-idle-state p {
|
||||
font-size: 12px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
GALLERY SECTION
|
||||
============================================ */
|
||||
.sstv-general-gallery-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1.5;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.sstv-general-gallery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sstv-general-gallery-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sstv-general-gallery-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan);
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sstv-general-gallery-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.sstv-general-image-card {
|
||||
position: relative;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sstv-general-image-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.sstv-general-image-card-inner {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sstv-general-image-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
background: #000;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sstv-general-image-actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sstv-general-image-card:hover .sstv-general-image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sstv-general-image-actions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sstv-general-image-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.sstv-general-image-actions button:last-child:hover {
|
||||
background: var(--accent-red, #ff3366);
|
||||
border-color: var(--accent-red, #ff3366);
|
||||
}
|
||||
|
||||
.sstv-general-image-info {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sstv-general-image-mode {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sstv-general-image-timestamp {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Empty gallery state */
|
||||
.sstv-general-gallery-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sstv-general-gallery-empty svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL MONITOR
|
||||
============================================ */
|
||||
.sstv-general-signal-monitor {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sstv-general-signal-monitor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sstv-general-signal-monitor-header svg {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sstv-general-signal-level-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sstv-general-signal-level-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sstv-general-signal-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sstv-general-signal-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.sstv-general-signal-level-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sstv-general-signal-status-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sstv-general-signal-vis-state {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sstv-general-signal-vis-state.active {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
IMAGE MODAL
|
||||
============================================ */
|
||||
.sstv-general-image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.sstv-general-image-modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sstv-general-image-modal img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sstv-general-modal-toolbar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 60px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sstv-general-modal-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sstv-general-modal-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.sstv-general-modal-btn.delete:hover {
|
||||
background: var(--accent-red, #ff3366);
|
||||
border-color: var(--accent-red, #ff3366);
|
||||
}
|
||||
|
||||
.sstv-general-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sstv-general-modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Clear All button */
|
||||
.sstv-general-gallery-clear-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.sstv-general-gallery-clear-btn:hover {
|
||||
color: var(--accent-red, #ff3366);
|
||||
border-color: var(--accent-red, #ff3366);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
@media (max-width: 1024px) {
|
||||
.sstv-general-main-row {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sstv-general-live-section {
|
||||
max-width: none;
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.sstv-general-gallery-section {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sstv-general-stats-strip {
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sstv-general-strip-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sstv-general-gallery-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sstv-general-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||