mirror of
https://github.com/smittix/intercept.git
synced 2026-06-20 03:14:21 -07:00
Compare commits
286 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f795180c7d | |||
| d1f1ce1f4b | |||
| 334073089f | |||
| df634dc741 | |||
| a76dfde02d | |||
| cc5ccf75a2 | |||
| 36f8349bc7 | |||
| 130a3a2d8e | |||
| bd6fa27970 | |||
| 630bc2971a | |||
| 7182f7803a | |||
| a64a7c414c | |||
| f0cc396a6b | |||
| 5f588a5513 | |||
| 599df7734b | |||
| 49fa02142d | |||
| 333dc00ee2 | |||
| 2bc71e44ad | |||
| 92265da5fb | |||
| 9c1516c086 | |||
| cd7940bdc2 | |||
| 4a5f3e1802 | |||
| 1b5bf4c061 | |||
| 384d02649a | |||
| d51da40a67 |
@@ -35,6 +35,7 @@ htmlcov/
|
|||||||
|
|
||||||
# Local Postgres data
|
# Local Postgres data
|
||||||
pgdata/
|
pgdata/
|
||||||
|
pgdata.bak/
|
||||||
|
|
||||||
# Captured files (don't include in image)
|
# Captured files (don't include in image)
|
||||||
*.cap
|
*.cap
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Uncomment and set to use external storage for ADS-B history
|
||||||
|
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||||
+11
@@ -54,3 +54,14 @@ intercept_agent_*.cfg
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
/tmp/
|
/tmp/
|
||||||
*.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
|
||||||
|
|||||||
+133
@@ -2,6 +2,139 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [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
|
## [2.12.0] - 2026-01-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,11 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, 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
|
## 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
|
```bash
|
||||||
# Initial setup (installs dependencies and configures SDR tools)
|
# Initial setup (installs dependencies and configures SDR tools)
|
||||||
./setup.sh
|
./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)
|
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||||
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||||
- `satellite.py` - Pass prediction using TLE data
|
- `satellite.py` - Pass prediction using TLE data
|
||||||
|
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||||
|
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||||
|
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||||
- `aprs.py` - Amateur packet radio via direwolf
|
- `aprs.py` - Amateur packet radio via direwolf
|
||||||
- `rtlamr.py` - Utility meter reading
|
- `rtlamr.py` - Utility meter reading
|
||||||
|
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||||
|
|
||||||
### Core Utilities (utils/)
|
### Core Utilities (utils/)
|
||||||
|
|
||||||
@@ -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)
|
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||||
- `channel_analyzer.py` - Frequency band analysis
|
- `channel_analyzer.py` - Frequency band analysis
|
||||||
|
|
||||||
|
**Weather Satellite** (`utils/weather_sat.py`):
|
||||||
|
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||||
|
- Subprocess management with stdout parsing, image watcher via rglob
|
||||||
|
- Pass prediction using skyfield TLE data
|
||||||
|
|
||||||
|
**SSTV Decoder** (`utils/sstv.py`):
|
||||||
|
- ISS SSTV reception via slowrx with Doppler tracking
|
||||||
|
- Singleton pattern, image gallery with timestamped filenames
|
||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
**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 |
|
| acarsdec | ACARS messages | Output parsing |
|
||||||
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||||
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||||
|
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||||
|
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||||
|
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||||
|
| direwolf | APRS | TNC modem for packet radio |
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||||
|
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||||
|
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||||
|
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
|
||||||
|
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||||
|
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||||
|
- Data persisted via `./data:/app/data` volume mount
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix
|
- `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
|
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|||||||
+104
-1
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||||
|
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||||
|
|
||||||
# Install system dependencies for SDR tools
|
# Install system dependencies for SDR tools
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
@@ -21,6 +24,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
multimon-ng \
|
multimon-ng \
|
||||||
# Audio tools for Listening Post
|
# Audio tools for Listening Post
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
# SSTV decoder runtime libs
|
||||||
|
libsndfile1 \
|
||||||
|
# SatDump runtime libs (weather satellite decoding)
|
||||||
|
libpng16-16 \
|
||||||
|
libtiff6 \
|
||||||
|
libjemalloc2 \
|
||||||
|
libvolk-bin \
|
||||||
|
libnng1 \
|
||||||
|
libzstd1 \
|
||||||
# WiFi tools (aircrack-ng suite)
|
# WiFi tools (aircrack-ng suite)
|
||||||
aircrack-ng \
|
aircrack-ng \
|
||||||
iw \
|
iw \
|
||||||
@@ -29,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
bluez \
|
bluez \
|
||||||
bluetooth \
|
bluetooth \
|
||||||
# GPS support
|
# GPS support
|
||||||
|
gpsd \
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
# Utilities
|
# Utilities
|
||||||
# APRS
|
# APRS
|
||||||
@@ -41,6 +54,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
soapysdr-module-rtlsdr \
|
soapysdr-module-rtlsdr \
|
||||||
soapysdr-module-hackrf \
|
soapysdr-module-hackrf \
|
||||||
soapysdr-module-lms7 \
|
soapysdr-module-lms7 \
|
||||||
|
soapysdr-module-airspy \
|
||||||
|
airspy \
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
hackrf \
|
||||||
# Utilities
|
# Utilities
|
||||||
@@ -56,13 +71,30 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cmake \
|
cmake \
|
||||||
libncurses-dev \
|
libncurses-dev \
|
||||||
libsndfile1-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 \
|
libsoapysdr-dev \
|
||||||
libhackrf-dev \
|
libhackrf-dev \
|
||||||
liblimesuite-dev \
|
liblimesuite-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
libzmq3-dev \
|
libzmq3-dev \
|
||||||
|
libpulse-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
liblapack-dev \
|
||||||
|
libcodec2-dev \
|
||||||
# Build dump1090
|
# Build dump1090
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
@@ -109,7 +141,66 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& make \
|
&& make \
|
||||||
&& cp acarsdec /usr/bin/acarsdec \
|
&& cp acarsdec /usr/bin/acarsdec \
|
||||||
&& rm -rf /tmp/acarsdec \
|
&& rm -rf /tmp/acarsdec \
|
||||||
|
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/windytan/slowrx.git \
|
||||||
|
&& cd slowrx \
|
||||||
|
&& git checkout ca6d7012 \
|
||||||
|
&& make \
|
||||||
|
&& install -m 0755 slowrx /usr/local/bin/slowrx \
|
||||||
|
&& rm -rf /tmp/slowrx \
|
||||||
|
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||||
|
&& cd SatDump \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
|
||||||
|
&& mkdir -p /usr/local/lib/satdump/plugins \
|
||||||
|
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
|
||||||
|
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
|
||||||
|
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
|
||||||
|
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
fi \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/SatDump \
|
||||||
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||||
|
&& export PATH="$PATH:/usr/local/go/bin" \
|
||||||
|
&& export GOPATH=/tmp/gopath \
|
||||||
|
&& go install github.com/bemasher/rtlamr@latest \
|
||||||
|
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||||
|
&& rm -rf /usr/local/go /tmp/gopath \
|
||||||
|
# Build mbelib (required by DSD)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone https://github.com/lwvmobile/mbelib.git \
|
||||||
|
&& cd mbelib \
|
||||||
|
&& (git checkout ambe_tones || true) \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/mbelib \
|
||||||
|
# Build DSD-FME (Digital Speech Decoder for DMR/P25)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \
|
||||||
|
&& cd dsd-fme \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/dsd-fme \
|
||||||
# Cleanup build tools to reduce image size
|
# Cleanup build tools to reduce image size
|
||||||
|
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||||
&& apt-get remove -y \
|
&& apt-get remove -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
git \
|
git \
|
||||||
@@ -117,6 +208,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
cmake \
|
cmake \
|
||||||
libncurses-dev \
|
libncurses-dev \
|
||||||
libsndfile1-dev \
|
libsndfile1-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
libsoapysdr-dev \
|
libsoapysdr-dev \
|
||||||
libhackrf-dev \
|
libhackrf-dev \
|
||||||
liblimesuite-dev \
|
liblimesuite-dev \
|
||||||
@@ -124,6 +223,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
libzmq3-dev \
|
libzmq3-dev \
|
||||||
|
libpulse-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
liblapack-dev \
|
||||||
|
libcodec2-dev \
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -135,7 +238,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory for persistence
|
# Create data directory for persistence
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data /app/data/weather_sat
|
||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
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
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
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:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
1. Definitions.
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
the copyright owner that is granting the License.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
"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.
|
||||||
|
|||||||
@@ -28,14 +28,24 @@ Support the developer of this open-source project
|
|||||||
|
|
||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
|
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
||||||
|
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
||||||
|
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
||||||
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
|
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||||
|
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
|
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||||
|
- **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
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
@@ -55,15 +65,54 @@ cd intercept
|
|||||||
sudo -E venv/bin/python intercept.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker (Alternative)
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker compose up -d
|
docker compose --profile basic up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
|
||||||
|
|
||||||
|
#### Multi-Architecture Builds (amd64 + arm64)
|
||||||
|
|
||||||
|
Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time setup on your x64 build machine
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
docker buildx create --name intercept-builder --use --bootstrap
|
||||||
|
|
||||||
|
# Build and push for both architectures
|
||||||
|
REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
|
||||||
|
|
||||||
|
# On the RPi5, just pull and run
|
||||||
|
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Build script options:
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `--push` | Push to container registry |
|
||||||
|
| `--load` | Load into local Docker (single platform only) |
|
||||||
|
| `--arm64-only` | Build arm64 only (for RPi deployment) |
|
||||||
|
| `--amd64-only` | Build amd64 only |
|
||||||
|
|
||||||
|
Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
|
||||||
|
|
||||||
|
#### Using a Pre-built Image
|
||||||
|
|
||||||
|
If you've pushed to a registry, you can skip building entirely on the target machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set in .env or export
|
||||||
|
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
||||||
|
|
||||||
|
# Then just run
|
||||||
|
docker compose --profile basic up -d
|
||||||
|
```
|
||||||
|
|
||||||
### ADS-B History (Optional)
|
### ADS-B History (Optional)
|
||||||
|
|
||||||
@@ -74,6 +123,47 @@ The ADS-B history feature persists aircraft messages to Postgres for long-term a
|
|||||||
docker compose --profile history up -d
|
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.
|
Then open **/adsb/history** for the reporting dashboard.
|
||||||
|
|
||||||
### Open the Interface
|
### Open the Interface
|
||||||
@@ -138,7 +228,7 @@ This project was developed using AI as a coding partner, combining human directi
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
Apache 2.0 License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
@@ -152,8 +242,11 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[dump1090](https://github.com/flightaware/dump1090) |
|
[dump1090](https://github.com/flightaware/dump1090) |
|
||||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
|
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
|
[SatDump](https://github.com/SatDump/SatDump) |
|
||||||
[Celestrak](https://celestrak.org/) |
|
[Celestrak](https://celestrak.org/) |
|
||||||
[Priyom.org](https://priyom.org/)
|
[Priyom.org](https://priyom.org/)
|
||||||
|
|
||||||
@@ -165,3 +258,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-01-11_fae1348c",
|
"version": "2026-02-01_ba81b697",
|
||||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||||
}
|
}
|
||||||
@@ -27,9 +27,9 @@ 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
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
from utils.process import cleanup_stale_processes
|
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
from utils.cleanup import DataStore, cleanup_manager
|
from utils.cleanup import DataStore, cleanup_manager
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -38,6 +38,7 @@ from utils.constants import (
|
|||||||
MAX_BT_DEVICE_AGE_SECONDS,
|
MAX_BT_DEVICE_AGE_SECONDS,
|
||||||
MAX_VESSEL_AGE_SECONDS,
|
MAX_VESSEL_AGE_SECONDS,
|
||||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||||
|
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
@@ -104,7 +105,7 @@ def inject_offline_settings():
|
|||||||
'enabled': get_setting('offline.enabled', False),
|
'enabled': get_setting('offline.enabled', False),
|
||||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||||
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,10 +172,25 @@ dsc_rtl_process = None
|
|||||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
dsc_lock = threading.Lock()
|
dsc_lock = threading.Lock()
|
||||||
|
|
||||||
|
# DMR / Digital Voice
|
||||||
|
dmr_process = None
|
||||||
|
dmr_rtl_process = None
|
||||||
|
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
dmr_lock = threading.Lock()
|
||||||
|
|
||||||
# TSCM (Technical Surveillance Countermeasures)
|
# TSCM (Technical Surveillance Countermeasures)
|
||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
|
# SubGHz Transceiver (HackRF)
|
||||||
|
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
subghz_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Deauth Attack Detection
|
||||||
|
deauth_detector = None
|
||||||
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
deauth_detector_lock = threading.Lock()
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL STATE DICTIONARIES
|
# GLOBAL STATE DICTIONARIES
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -204,6 +220,9 @@ ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessel
|
|||||||
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
||||||
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
||||||
|
|
||||||
|
# Deauth alerts - using DataStore for automatic cleanup
|
||||||
|
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
||||||
|
|
||||||
# Satellite state
|
# Satellite state
|
||||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||||
|
|
||||||
@@ -215,6 +234,67 @@ cleanup_manager.register(bt_beacons)
|
|||||||
cleanup_manager.register(adsb_aircraft)
|
cleanup_manager.register(adsb_aircraft)
|
||||||
cleanup_manager.register(ais_vessels)
|
cleanup_manager.register(ais_vessels)
|
||||||
cleanup_manager.register(dsc_messages)
|
cleanup_manager.register(dsc_messages)
|
||||||
|
cleanup_manager.register(deauth_alerts)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SDR DEVICE REGISTRY
|
||||||
|
# ============================================
|
||||||
|
# Tracks which mode is using which SDR device to prevent conflicts
|
||||||
|
# Key: device_index (int), Value: mode_name (str)
|
||||||
|
sdr_device_registry: dict[int, str] = {}
|
||||||
|
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')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Error message if device is in use, None if successfully claimed
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
if device_index in sdr_device_registry:
|
||||||
|
in_use_by = sdr_device_registry[device_index]
|
||||||
|
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
def release_sdr_device(device_index: int) -> None:
|
||||||
|
"""Release an SDR device from the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: The SDR device index to release
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
sdr_device_registry.pop(device_index, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sdr_device_status() -> dict[int, str]:
|
||||||
|
"""Get current SDR device allocations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping device indices to mode names
|
||||||
|
"""
|
||||||
|
with sdr_device_registry_lock:
|
||||||
|
return dict(sdr_device_registry)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -226,6 +306,14 @@ def require_login():
|
|||||||
# Routes that don't require login (to avoid infinite redirect loop)
|
# Routes that don't require login (to avoid infinite redirect loop)
|
||||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
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
|
# Controller API endpoints use API key auth, not session auth
|
||||||
# Allow agent push/pull endpoints without session login
|
# Allow agent push/pull endpoints without session login
|
||||||
if request.path.startswith('/controller/'):
|
if request.path.startswith('/controller/'):
|
||||||
@@ -279,7 +367,16 @@ def index() -> str:
|
|||||||
'rtlamr': check_tool('rtlamr')
|
'rtlamr': check_tool('rtlamr')
|
||||||
}
|
}
|
||||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
tools=tools,
|
||||||
|
devices=devices,
|
||||||
|
version=VERSION,
|
||||||
|
changelog=CHANGELOG,
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
default_latitude=DEFAULT_LATITUDE,
|
||||||
|
default_longitude=DEFAULT_LONGITUDE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/favicon.svg')
|
@app.route('/favicon.svg')
|
||||||
@@ -294,6 +391,22 @@ def get_devices() -> Response:
|
|||||||
return jsonify([d.to_dict() for d in devices])
|
return jsonify([d.to_dict() for d in devices])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/devices/status')
|
||||||
|
def get_devices_status() -> Response:
|
||||||
|
"""Get all SDR devices with usage status."""
|
||||||
|
devices = SDRFactory.detect_devices()
|
||||||
|
registry = get_sdr_device_status()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for device in devices:
|
||||||
|
d = device.to_dict()
|
||||||
|
d['in_use'] = device.index in registry
|
||||||
|
d['used_by'] = registry.get(device.index)
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices/debug')
|
@app.route('/devices/debug')
|
||||||
def get_devices_debug() -> Response:
|
def get_devices_debug() -> Response:
|
||||||
"""Get detailed SDR device detection diagnostics."""
|
"""Get detailed SDR device detection diagnostics."""
|
||||||
@@ -534,6 +647,25 @@ 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_dmr_active() -> bool:
|
||||||
|
"""Check if Digital Voice decoder has an active process."""
|
||||||
|
try:
|
||||||
|
from routes import dmr as dmr_module
|
||||||
|
proc = dmr_module.dmr_dsd_process
|
||||||
|
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
@@ -552,6 +684,8 @@ def health_check() -> Response:
|
|||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_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),
|
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
|
'dmr': _get_dmr_active(),
|
||||||
|
'subghz': _get_subghz_active(),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
@@ -566,19 +700,24 @@ def health_check() -> Response:
|
|||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
def kill_all() -> Response:
|
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 current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
|
global dmr_process, dmr_rtl_process
|
||||||
|
|
||||||
# Import adsb and ais modules to reset their state
|
# Import adsb and ais modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
from routes import ais as ais_module
|
from routes import ais as ais_module
|
||||||
|
from utils.bluetooth import reset_bluetooth_scanner
|
||||||
|
|
||||||
killed = []
|
killed = []
|
||||||
processes_to_kill = [
|
processes_to_kill = [
|
||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
|
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||||
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
|
'hackrf_transfer', 'hackrf_sweep'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -622,6 +761,42 @@ def kill_all() -> Response:
|
|||||||
dsc_process = None
|
dsc_process = None
|
||||||
dsc_rtl_process = None
|
dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Reset DMR state
|
||||||
|
with dmr_lock:
|
||||||
|
dmr_process = None
|
||||||
|
dmr_rtl_process = None
|
||||||
|
|
||||||
|
# Reset Bluetooth state (legacy)
|
||||||
|
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()
|
||||||
|
|
||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
@@ -702,11 +877,24 @@ def main() -> None:
|
|||||||
|
|
||||||
# Clean up any stale processes from previous runs
|
# Clean up any stale processes from previous runs
|
||||||
cleanup_stale_processes()
|
cleanup_stale_processes()
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
|
||||||
# Initialize database for settings storage
|
# Initialize database for settings storage
|
||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
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
|
# Start automatic cleanup of stale data entries
|
||||||
cleanup_manager.start()
|
cleanup_manager.start()
|
||||||
|
|
||||||
@@ -714,6 +902,15 @@ def main() -> None:
|
|||||||
from routes import register_blueprints
|
from routes import register_blueprints
|
||||||
register_blueprints(app)
|
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)
|
# Update TLE data in background thread (non-blocking)
|
||||||
def update_tle_background():
|
def update_tle_background():
|
||||||
try:
|
try:
|
||||||
@@ -738,6 +935,22 @@ def main() -> None:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"WebSocket audio disabled (install flask-sock): {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(f"Open http://localhost:{args.port} in your browser")
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
|
|||||||
Executable
+139
@@ -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,10 +7,103 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.12.0"
|
VERSION = "2.18.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
||||||
|
"SDR device lock-up fix from unreleased device registry on crash",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.14.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
|
||||||
|
"DMR visual synthesizer with event-driven spring-physics bars",
|
||||||
|
"HF SSTV general mode with predefined shortwave frequencies",
|
||||||
|
"WebSDR integration for remote HF/shortwave listening",
|
||||||
|
"Listening Post signal scanner and audio pipeline improvements",
|
||||||
|
"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": [
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.12.0",
|
"version": "2.12.0",
|
||||||
"date": "January 2026",
|
"date": "January 2026",
|
||||||
@@ -139,6 +232,7 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
|||||||
# ADS-B settings
|
# ADS-B settings
|
||||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||||
|
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
|
||||||
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
||||||
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||||
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||||
@@ -149,20 +243,49 @@ ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
|||||||
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
||||||
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
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 settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||||
|
|
||||||
|
# Weather satellite settings
|
||||||
|
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||||
|
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 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
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||||
|
|
||||||
|
# Alerting
|
||||||
|
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
||||||
|
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
||||||
|
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||||
|
|
||||||
# Admin credentials
|
# Admin credentials
|
||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure application logging."""
|
"""Configure application logging."""
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
+38
-11
@@ -1,27 +1,31 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker Compose configuration for easy deployment
|
# Docker Compose configuration for easy deployment
|
||||||
#
|
#
|
||||||
# Basic usage:
|
# Basic usage (build locally):
|
||||||
# docker compose up -d
|
# docker compose --profile basic up -d --build
|
||||||
|
#
|
||||||
|
# Basic usage (pre-built image from registry):
|
||||||
|
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
||||||
#
|
#
|
||||||
# With ADS-B history (Postgres):
|
# With ADS-B history (Postgres):
|
||||||
# docker compose --profile history up -d
|
# docker compose --profile history up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
intercept:
|
intercept:
|
||||||
|
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
||||||
|
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept
|
container_name: intercept
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
# Alternatively, use device mapping (see below)
|
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping (alternative to privileged mode)
|
# USB device mapping for all USB devices
|
||||||
# devices:
|
devices:
|
||||||
# - /dev/bus/usb:/dev/bus/usb
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
# volumes:
|
volumes:
|
||||||
# Persist data directory
|
# Persist decoded images and database across container rebuilds
|
||||||
# - ./data:/app/data
|
- ./data:/app/data
|
||||||
# Optional: mount logs directory
|
# Optional: mount logs directory
|
||||||
# - ./logs:/app/logs
|
# - ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
@@ -36,6 +40,13 @@ services:
|
|||||||
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
# - INTERCEPT_ADSB_DB_USER=intercept
|
# - INTERCEPT_ADSB_DB_USER=intercept
|
||||||
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
|
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- 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 for WiFi scanning (requires host network)
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -49,15 +60,23 @@ services:
|
|||||||
# ADS-B history with Postgres persistence
|
# ADS-B history with Postgres persistence
|
||||||
# Enable with: docker compose --profile history up -d
|
# Enable with: docker compose --profile history up -d
|
||||||
intercept-history:
|
intercept-history:
|
||||||
|
# Same image/build fallback pattern as above
|
||||||
|
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept
|
container_name: intercept-history
|
||||||
profiles:
|
profiles:
|
||||||
- history
|
- history
|
||||||
depends_on:
|
depends_on:
|
||||||
- adsb_db
|
- adsb_db
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
|
# USB device mapping for all USB devices
|
||||||
|
devices:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
@@ -68,6 +87,13 @@ services:
|
|||||||
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
- INTERCEPT_ADSB_DB_USER=intercept
|
- INTERCEPT_ADSB_DB_USER=intercept
|
||||||
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- 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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||||
@@ -86,7 +112,8 @@ services:
|
|||||||
- POSTGRES_USER=intercept
|
- POSTGRES_USER=intercept
|
||||||
- POSTGRES_PASSWORD=intercept
|
- POSTGRES_PASSWORD=intercept
|
||||||
volumes:
|
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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
www.intercept-sigint.com
|
||||||
@@ -16,6 +16,14 @@ Complete feature list for all modules.
|
|||||||
- **Doorbells, remotes, and IoT devices**
|
- **Doorbells, remotes, and IoT devices**
|
||||||
- **Smart meters** and utility monitors
|
- **Smart meters** and utility monitors
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
|
||||||
|
- **Protocol decoding** - identify and decode common Sub-GHz protocols
|
||||||
|
- **Signal replay/transmit** capabilities for authorized testing
|
||||||
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
|
- **I/Q capture** - record raw samples for offline analysis
|
||||||
|
|
||||||
## AIS Vessel Tracking
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||||
@@ -84,6 +92,55 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||||
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||||
|
|
||||||
|
## ACARS Messaging
|
||||||
|
|
||||||
|
- **Real-time ACARS decoding** via acarsdec
|
||||||
|
- **Aircraft datalink messages** - operational, weather, and position reports
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
- **Message filtering** - filter by message type, flight, or registration
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||||
|
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||||
|
- **Cross-module frequency routing** from scanner to decoders
|
||||||
|
- **Customizable frequency presets** and band bookmarks
|
||||||
|
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
|
||||||
|
- **Auto-scheduler** with pass prediction and automatic capture
|
||||||
|
- **Polar plot** - real-time satellite position on azimuth/elevation display
|
||||||
|
- **Ground track map** - orbit path with past/future trajectory
|
||||||
|
- **Image gallery** with timestamped decoded imagery
|
||||||
|
|
||||||
|
## WebSDR
|
||||||
|
|
||||||
|
- **KiwiSDR network integration** for remote HF/shortwave listening
|
||||||
|
- **WebSocket audio streaming** from remote receivers
|
||||||
|
- **Receiver discovery** with automatic caching
|
||||||
|
- **Frequency tuning** with band presets
|
||||||
|
|
||||||
|
## HF SSTV
|
||||||
|
|
||||||
|
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||||
|
- **Predefined frequency lookup** for active SSTV calling frequencies
|
||||||
|
- **Image gallery** with decoded transmissions
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
- **Amateur packet radio** position reports and telemetry via direwolf
|
||||||
|
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
|
||||||
|
- **Real-time position tracking** on interactive map
|
||||||
|
- **Message and telemetry display** from APRS network
|
||||||
|
|
||||||
|
## Utility Meter Reading
|
||||||
|
|
||||||
|
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
|
||||||
|
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||||
|
- **Multiple meter protocol support** via rtl_tcp integration
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
@@ -131,6 +188,52 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **Proximity radar** visualization
|
- **Proximity radar** visualization
|
||||||
- **Device type breakdown** chart
|
- **Device type breakdown** chart
|
||||||
|
|
||||||
|
## BT Locate (SAR Bluetooth Device Location)
|
||||||
|
|
||||||
|
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
||||||
|
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
||||||
|
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
||||||
|
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
||||||
|
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
||||||
|
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
||||||
|
- **Distance estimation** - Log-distance path loss model with environment presets
|
||||||
|
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
||||||
|
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
||||||
|
|
||||||
|
### Environment Presets
|
||||||
|
- **Open Field** (n=2.0) - Free space path loss
|
||||||
|
- **Outdoor** (n=2.2) - Typical outdoor environment
|
||||||
|
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
||||||
|
|
||||||
|
### Map & Trail
|
||||||
|
- Interactive Leaflet map with GPS trail visualization
|
||||||
|
- Trail points color-coded by proximity band
|
||||||
|
- Polyline connecting detection points for path visualization
|
||||||
|
- Supports user-configured tile providers
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Bluetooth adapter (built-in or USB)
|
||||||
|
- GPS receiver (optional, falls back to manual coordinates)
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
Real-time GPS position tracking with live map visualization.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
||||||
|
- **Interactive map** - Current position on Leaflet map with track history
|
||||||
|
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
||||||
|
- **Satellite info** - Number of satellites in view and fix quality
|
||||||
|
- **Track recording** - Record GPS tracks with export capability
|
||||||
|
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- USB GPS receiver connected via gpsd
|
||||||
|
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
||||||
|
|
||||||
## TSCM Counter-Surveillance Mode
|
## TSCM Counter-Surveillance Mode
|
||||||
|
|
||||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||||
|
|||||||
+162
-1
@@ -57,6 +57,32 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- Samsung SmartTag
|
- Samsung SmartTag
|
||||||
- Chipolo
|
- Chipolo
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
1. **Connect HackRF** - Plug in your HackRF One device
|
||||||
|
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
|
||||||
|
3. **Start Capture** - Click "Start Capture" to begin signal analysis
|
||||||
|
4. **View Spectrum** - Real-time spectrum visualization of the selected band
|
||||||
|
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
|
||||||
|
|
||||||
|
### Supported Protocols
|
||||||
|
|
||||||
|
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency Range** - Define start and end frequencies for scanning
|
||||||
|
3. **Start Scanning** - Click "Start Scan" for wideband sweep
|
||||||
|
4. **View Signals** - Discovered signals are listed with frequency and SNR
|
||||||
|
5. **Tune In** - Click a signal to tune the audio demodulator
|
||||||
|
6. **Listen** - Real-time audio plays in your browser
|
||||||
|
|
||||||
|
### Demodulation Modes
|
||||||
|
|
||||||
|
- **FM** - Narrowband and wideband FM
|
||||||
|
- **SSB** - Upper and lower sideband for amateur radio and shortwave
|
||||||
|
|
||||||
## Aircraft Mode (ADS-B)
|
## Aircraft Mode (ADS-B)
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||||
@@ -65,6 +91,8 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- **Manual Entry** - Type coordinates directly
|
- **Manual Entry** - Type coordinates directly
|
||||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
- **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
|
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||||
6. **Click Aircraft** - Click markers for detailed information
|
6. **Click Aircraft** - Click markers for detailed information
|
||||||
@@ -72,6 +100,9 @@ INTERCEPT automatically detects known trackers:
|
|||||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
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
|
### Emergency Squawks
|
||||||
|
|
||||||
The system highlights aircraft transmitting emergency squawks:
|
The system highlights aircraft transmitting emergency squawks:
|
||||||
@@ -96,12 +127,40 @@ Set the following environment variables (Docker recommended):
|
|||||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
| `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 Setup
|
||||||
|
|
||||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
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
|
### Using the History Dashboard
|
||||||
@@ -111,6 +170,8 @@ docker compose up -d
|
|||||||
3. View aircraft history and timelines
|
3. View aircraft history and timelines
|
||||||
4. Stop tracking when desired (session history is recorded)
|
4. Stop tracking when desired (session history is recorded)
|
||||||
|
|
||||||
|
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
|
||||||
|
|
||||||
## Satellite Mode
|
## Satellite Mode
|
||||||
|
|
||||||
1. **Set Location** - Choose location source:
|
1. **Set Location** - Choose location source:
|
||||||
@@ -130,6 +191,106 @@ docker compose up -d
|
|||||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||||
4. Select satellites to add
|
4. Select satellites to add
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
1. **Set Location** - Enter observer coordinates or use GPS
|
||||||
|
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
|
||||||
|
3. **View Passes** - Upcoming passes shown with polar plot and ground track
|
||||||
|
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
|
||||||
|
5. **View Images** - Decoded imagery appears in the gallery
|
||||||
|
|
||||||
|
### Auto-Scheduler
|
||||||
|
|
||||||
|
Enable the auto-scheduler to automatically capture passes:
|
||||||
|
- Calculates upcoming NOAA and Meteor passes for your location
|
||||||
|
- Starts SatDump at the correct time and frequency
|
||||||
|
- Decoded images are saved with timestamps
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
|
||||||
|
3. **View Map** - Vessels appear on the interactive maritime map
|
||||||
|
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
|
||||||
|
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
|
||||||
|
|
||||||
|
### VHF DSC Channel 70
|
||||||
|
|
||||||
|
Digital Selective Calling monitoring runs alongside AIS:
|
||||||
|
- Distress, Urgency, Safety, and Routine messages
|
||||||
|
- Distress positions plotted with pulsing alert markers
|
||||||
|
- Audio alerts for critical messages
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
|
||||||
|
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
|
||||||
|
4. **View Map** - Station positions appear on the interactive map
|
||||||
|
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
|
||||||
|
|
||||||
|
## Utility Meters
|
||||||
|
|
||||||
|
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
|
||||||
|
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
|
||||||
|
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
|
||||||
|
|
||||||
|
## BT Locate (SAR Device Location)
|
||||||
|
|
||||||
|
1. **Set Target** - Enter one or more target identifiers:
|
||||||
|
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
|
||||||
|
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
|
||||||
|
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
|
||||||
|
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
|
||||||
|
2. **Choose Environment** - Select the RF environment preset:
|
||||||
|
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||||
|
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
|
||||||
|
- **Indoor** (n=3.0) - For buildings with walls and obstacles
|
||||||
|
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||||
|
4. **Monitor HUD** - The proximity display shows:
|
||||||
|
- Proximity band (IMMEDIATE / NEAR / FAR)
|
||||||
|
- Estimated distance in meters
|
||||||
|
- Raw RSSI and smoothed RSSI average
|
||||||
|
- Detection count and GPS-tagged points
|
||||||
|
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||||
|
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
|
||||||
|
7. **Review Trail** - Check the map for GPS-tagged detection trail
|
||||||
|
|
||||||
|
### Hand-off from Bluetooth Mode
|
||||||
|
|
||||||
|
1. Open Bluetooth scanning mode and find the target device
|
||||||
|
2. Click the "Locate" button on the device card
|
||||||
|
3. BT Locate opens with the device pre-filled
|
||||||
|
4. Click "Start Locate" to begin tracking
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- For devices with address randomization (iPhones, modern Android), use the IRK method
|
||||||
|
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
|
||||||
|
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
|
||||||
|
- Clear the trail when starting a new search area
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
||||||
|
2. **View Map** - Your position appears on the interactive map with a track trail
|
||||||
|
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
|
||||||
|
4. **Record Track** - Enable track recording to save your path
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
|
||||||
|
- GPS fix may take 30-60 seconds after cold start
|
||||||
|
- Accuracy improves with more satellites in view
|
||||||
|
|
||||||
|
## Meshtastic
|
||||||
|
|
||||||
|
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||||
|
2. **Start** - Click "Start" to connect to the mesh network
|
||||||
|
3. **View Messages** - Real-time message stream from the mesh
|
||||||
|
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
||||||
|
5. **Send Messages** - Type messages to broadcast on the mesh
|
||||||
|
|
||||||
## Remote Agents (Distributed SIGINT)
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
+59
-1
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">15+</span>
|
<span class="stat-value">20+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -77,6 +77,12 @@
|
|||||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📻</div>
|
||||||
|
<h3>Sub-GHz Analyzer</h3>
|
||||||
|
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">📻</div>
|
<div class="feature-icon">📻</div>
|
||||||
<h3>Listening Post</h3>
|
<h3>Listening Post</h3>
|
||||||
@@ -101,6 +107,18 @@
|
|||||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📍</div>
|
||||||
|
<h3>BT Locate</h3>
|
||||||
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🛰️</div>
|
||||||
|
<h3>GPS Tracking</h3>
|
||||||
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">🛡️</div>
|
<div class="feature-icon">🛡️</div>
|
||||||
<h3>TSCM</h3>
|
<h3>TSCM</h3>
|
||||||
@@ -143,11 +161,47 @@
|
|||||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌧️</div>
|
||||||
|
<h3>Weather Satellites</h3>
|
||||||
|
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">🖼️</div>
|
<div class="feature-icon">🖼️</div>
|
||||||
<h3>ISS SSTV</h3>
|
<h3>ISS SSTV</h3>
|
||||||
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🖼️</div>
|
||||||
|
<h3>HF SSTV</h3>
|
||||||
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">✈️</div>
|
||||||
|
<h3>ACARS</h3>
|
||||||
|
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📍</div>
|
||||||
|
<h3>APRS</h3>
|
||||||
|
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌐</div>
|
||||||
|
<h3>WebSDR</h3>
|
||||||
|
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Utility Meters</h3>
|
||||||
|
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -194,6 +248,10 @@
|
|||||||
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||||
<span class="screenshot-label">AIS Vessel Tracking</span>
|
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||||
|
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Executable
+30
@@ -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
|
||||||
+393
-50
@@ -843,6 +843,7 @@ class ModeManager:
|
|||||||
'anomalies': getattr(self, 'tscm_anomalies', []),
|
'anomalies': getattr(self, 'tscm_anomalies', []),
|
||||||
'baseline': getattr(self, 'tscm_baseline', {}),
|
'baseline': getattr(self, 'tscm_baseline', {}),
|
||||||
'wifi_devices': list(self.wifi_networks.values()),
|
'wifi_devices': list(self.wifi_networks.values()),
|
||||||
|
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
||||||
'bt_devices': list(self.bluetooth_devices.values()),
|
'bt_devices': list(self.bluetooth_devices.values()),
|
||||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
||||||
}
|
}
|
||||||
@@ -872,6 +873,150 @@ class ModeManager:
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# WiFi Monitor Mode
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def toggle_monitor_mode(self, params: dict) -> dict:
|
||||||
|
"""Enable or disable monitor mode on a WiFi interface."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
action = params.get('action', 'start')
|
||||||
|
interface = params.get('interface', '')
|
||||||
|
kill_processes = params.get('kill_processes', False)
|
||||||
|
|
||||||
|
# Validate interface name (alphanumeric, underscore, dash only)
|
||||||
|
if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface):
|
||||||
|
return {'status': 'error', 'message': 'Invalid interface name'}
|
||||||
|
|
||||||
|
airmon_path = self._get_tool_path('airmon-ng')
|
||||||
|
iw_path = self._get_tool_path('iw')
|
||||||
|
|
||||||
|
if action == 'start':
|
||||||
|
if airmon_path:
|
||||||
|
try:
|
||||||
|
# Get interfaces before
|
||||||
|
def get_wireless_interfaces():
|
||||||
|
interfaces = set()
|
||||||
|
try:
|
||||||
|
for iface in os.listdir('/sys/class/net'):
|
||||||
|
if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface:
|
||||||
|
interfaces.add(iface)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
interfaces_before = get_wireless_interfaces()
|
||||||
|
|
||||||
|
# Kill interfering processes if requested
|
||||||
|
if kill_processes:
|
||||||
|
subprocess.run([airmon_path, 'check', 'kill'],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
|
||||||
|
# Start monitor mode
|
||||||
|
result = subprocess.run([airmon_path, 'start', interface],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
interfaces_after = get_wireless_interfaces()
|
||||||
|
|
||||||
|
# Find the new monitor interface
|
||||||
|
new_interfaces = interfaces_after - interfaces_before
|
||||||
|
monitor_iface = None
|
||||||
|
|
||||||
|
if new_interfaces:
|
||||||
|
for iface in new_interfaces:
|
||||||
|
if 'mon' in iface:
|
||||||
|
monitor_iface = iface
|
||||||
|
break
|
||||||
|
if not monitor_iface:
|
||||||
|
monitor_iface = list(new_interfaces)[0]
|
||||||
|
|
||||||
|
# Try to parse from airmon-ng output
|
||||||
|
if not monitor_iface:
|
||||||
|
patterns = [
|
||||||
|
r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b',
|
||||||
|
r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)',
|
||||||
|
r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)',
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, output, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
candidate = match.group(1)
|
||||||
|
if candidate and not candidate[0].isdigit():
|
||||||
|
monitor_iface = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback: check if original interface is in monitor mode
|
||||||
|
if not monitor_iface:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['iwconfig', interface],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if 'Mode:Monitor' in result.stdout:
|
||||||
|
monitor_iface = interface
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Last resort: try common naming
|
||||||
|
if not monitor_iface:
|
||||||
|
potential = interface + 'mon'
|
||||||
|
if os.path.exists(f'/sys/class/net/{potential}'):
|
||||||
|
monitor_iface = potential
|
||||||
|
|
||||||
|
if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'):
|
||||||
|
all_wireless = list(get_wireless_interfaces())
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
||||||
|
}
|
||||||
|
|
||||||
|
self.wifi_monitor_interface = monitor_iface
|
||||||
|
self._capabilities = None # Invalidate cache so interfaces refresh
|
||||||
|
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||||
|
return {'status': 'success', 'monitor_interface': monitor_iface}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error enabling monitor mode: {e}")
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
|
elif iw_path:
|
||||||
|
try:
|
||||||
|
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||||
|
subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True)
|
||||||
|
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||||
|
self.wifi_monitor_interface = interface
|
||||||
|
self._capabilities = None # Invalidate cache
|
||||||
|
return {'status': 'success', 'monitor_interface': interface}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
else:
|
||||||
|
return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'}
|
||||||
|
|
||||||
|
else: # stop
|
||||||
|
current_iface = getattr(self, 'wifi_monitor_interface', None) or interface
|
||||||
|
if airmon_path:
|
||||||
|
try:
|
||||||
|
subprocess.run([airmon_path, 'stop', current_iface],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
self.wifi_monitor_interface = None
|
||||||
|
self._capabilities = None # Invalidate cache
|
||||||
|
return {'status': 'success', 'message': 'Monitor mode disabled'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
elif iw_path:
|
||||||
|
try:
|
||||||
|
subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True)
|
||||||
|
subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True)
|
||||||
|
subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True)
|
||||||
|
self.wifi_monitor_interface = None
|
||||||
|
self._capabilities = None # Invalidate cache
|
||||||
|
return {'status': 'success', 'message': 'Monitor mode disabled'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
|
return {'status': 'error', 'message': 'Unknown action'}
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Mode-specific implementations
|
# Mode-specific implementations
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -914,26 +1059,34 @@ class ModeManager:
|
|||||||
"""Internal mode stop - terminates processes and cleans up."""
|
"""Internal mode stop - terminates processes and cleans up."""
|
||||||
logger.info(f"Stopping mode {mode}")
|
logger.info(f"Stopping mode {mode}")
|
||||||
|
|
||||||
# Signal stop
|
# Signal stop first - this unblocks any waiting threads
|
||||||
if mode in self.stop_events:
|
if mode in self.stop_events:
|
||||||
self.stop_events[mode].set()
|
self.stop_events[mode].set()
|
||||||
|
|
||||||
# Terminate process if running
|
# Terminate process if running
|
||||||
if mode in self.processes:
|
if mode in self.processes:
|
||||||
proc = self.processes[mode]
|
proc = self.processes[mode]
|
||||||
if proc and proc.poll() is None:
|
try:
|
||||||
proc.terminate()
|
if proc and proc.poll() is None:
|
||||||
try:
|
proc.terminate()
|
||||||
proc.wait(timeout=3)
|
try:
|
||||||
except subprocess.TimeoutExpired:
|
proc.wait(timeout=2)
|
||||||
proc.kill()
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except (OSError, ProcessLookupError) as e:
|
||||||
|
# Process already dead or inaccessible
|
||||||
|
logger.debug(f"Process cleanup for {mode}: {e}")
|
||||||
del self.processes[mode]
|
del self.processes[mode]
|
||||||
|
|
||||||
# Wait for output thread
|
# Wait for output thread (short timeout since stop event is set)
|
||||||
if mode in self.output_threads:
|
if mode in self.output_threads:
|
||||||
thread = self.output_threads[mode]
|
thread = self.output_threads[mode]
|
||||||
if thread and thread.is_alive():
|
if thread and thread.is_alive():
|
||||||
thread.join(timeout=2)
|
thread.join(timeout=1)
|
||||||
del self.output_threads[mode]
|
del self.output_threads[mode]
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
@@ -964,6 +1117,7 @@ class ModeManager:
|
|||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
self.tscm_baseline = {}
|
self.tscm_baseline = {}
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
|
self.tscm_wifi_clients = {}
|
||||||
# Clear reported threat tracking sets
|
# Clear reported threat tracking sets
|
||||||
if hasattr(self, '_tscm_reported_wifi'):
|
if hasattr(self, '_tscm_reported_wifi'):
|
||||||
self._tscm_reported_wifi.clear()
|
self._tscm_reported_wifi.clear()
|
||||||
@@ -1137,10 +1291,16 @@ class ModeManager:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass # Not JSON, ignore
|
pass # Not JSON, ignore
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
# Bad file descriptor or closed file - process was terminated
|
||||||
|
logger.debug(f"Sensor output reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Sensor output reader error: {e}")
|
logger.error(f"Sensor output reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Sensor output reader stopped")
|
logger.info("Sensor output reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -1383,6 +1543,7 @@ class ModeManager:
|
|||||||
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
||||||
interface = params.get('interface')
|
interface = params.get('interface')
|
||||||
channel = params.get('channel')
|
channel = params.get('channel')
|
||||||
|
channels = params.get('channels')
|
||||||
band = params.get('band', 'abg')
|
band = params.get('band', 'abg')
|
||||||
scan_type = params.get('scan_type', 'deep')
|
scan_type = params.get('scan_type', 'deep')
|
||||||
|
|
||||||
@@ -1413,8 +1574,21 @@ class ModeManager:
|
|||||||
else:
|
else:
|
||||||
scan_band = 'all'
|
scan_band = 'all'
|
||||||
|
|
||||||
|
channel_list = None
|
||||||
|
if channels:
|
||||||
|
if isinstance(channels, str):
|
||||||
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
|
channel_list = list(channels)
|
||||||
|
else:
|
||||||
|
channel_list = [channels]
|
||||||
|
try:
|
||||||
|
channel_list = [int(c) for c in channel_list]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'message': 'Invalid channels'}
|
||||||
|
|
||||||
# Start deep scan
|
# Start deep scan
|
||||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
|
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
||||||
# Start thread to sync data to agent's dictionaries
|
# Start thread to sync data to agent's dictionaries
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._wifi_data_sync,
|
target=self._wifi_data_sync,
|
||||||
@@ -1435,7 +1609,7 @@ class ModeManager:
|
|||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback to direct airodump-ng
|
# Fallback to direct airodump-ng
|
||||||
return self._start_wifi_fallback(interface, channel, band)
|
return self._start_wifi_fallback(interface, channel, band, channels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WiFi scanner error: {e}")
|
logger.error(f"WiFi scanner error: {e}")
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
@@ -1472,7 +1646,13 @@ class ModeManager:
|
|||||||
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
||||||
self._wifi_scanner_instance.stop_deep_scan()
|
self._wifi_scanner_instance.stop_deep_scan()
|
||||||
|
|
||||||
def _start_wifi_fallback(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."""
|
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
return {'status': 'error', 'message': 'WiFi interface required'}
|
||||||
@@ -1500,7 +1680,22 @@ class ModeManager:
|
|||||||
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
||||||
if gps_manager.is_running:
|
if gps_manager.is_running:
|
||||||
cmd.append('--gpsd')
|
cmd.append('--gpsd')
|
||||||
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.extend(['-c', str(channel)])
|
||||||
cmd.append(interface)
|
cmd.append(interface)
|
||||||
|
|
||||||
@@ -1827,7 +2022,7 @@ class ModeManager:
|
|||||||
'agent_gps': gps_manager.position
|
'agent_gps': gps_manager.position
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner.set_on_device_updated(on_device_updated)
|
scanner.add_device_callback(on_device_updated)
|
||||||
|
|
||||||
# Start scanning
|
# Start scanning
|
||||||
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
||||||
@@ -2102,15 +2297,24 @@ class ModeManager:
|
|||||||
|
|
||||||
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
|
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
# Bad file descriptor or closed file - process was terminated
|
||||||
|
logger.debug(f"Pager reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Pager reader error: {e}")
|
logger.error(f"Pager reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'pager_rtl' in self.processes:
|
if 'pager_rtl' in self.processes:
|
||||||
rtl_proc = self.processes['pager_rtl']
|
try:
|
||||||
if rtl_proc.poll() is None:
|
rtl_proc = self.processes['pager_rtl']
|
||||||
rtl_proc.terminate()
|
if rtl_proc.poll() is None:
|
||||||
del self.processes['pager_rtl']
|
rtl_proc.terminate()
|
||||||
|
del self.processes['pager_rtl']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Pager reader stopped")
|
logger.info("Pager reader stopped")
|
||||||
|
|
||||||
def _parse_pager_message(self, line: str) -> dict | None:
|
def _parse_pager_message(self, line: str) -> dict | None:
|
||||||
@@ -2492,10 +2696,15 @@ class ModeManager:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"ACARS reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ACARS reader error: {e}")
|
logger.error(f"ACARS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("ACARS reader stopped")
|
logger.info("ACARS reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2632,15 +2841,23 @@ class ModeManager:
|
|||||||
|
|
||||||
logger.debug(f"APRS: {callsign}")
|
logger.debug(f"APRS: {callsign}")
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"APRS reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"APRS reader error: {e}")
|
logger.error(f"APRS reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'aprs_rtl' in self.processes:
|
if 'aprs_rtl' in self.processes:
|
||||||
rtl_proc = self.processes['aprs_rtl']
|
try:
|
||||||
if rtl_proc.poll() is None:
|
rtl_proc = self.processes['aprs_rtl']
|
||||||
rtl_proc.terminate()
|
if rtl_proc.poll() is None:
|
||||||
del self.processes['aprs_rtl']
|
rtl_proc.terminate()
|
||||||
|
del self.processes['aprs_rtl']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("APRS reader stopped")
|
logger.info("APRS reader stopped")
|
||||||
|
|
||||||
def _parse_aprs_packet(self, line: str) -> dict | None:
|
def _parse_aprs_packet(self, line: str) -> dict | None:
|
||||||
@@ -2788,15 +3005,23 @@ class ModeManager:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"RTLAMR reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"RTLAMR reader error: {e}")
|
logger.error(f"RTLAMR reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if 'rtlamr_tcp' in self.processes:
|
if 'rtlamr_tcp' in self.processes:
|
||||||
tcp_proc = self.processes['rtlamr_tcp']
|
try:
|
||||||
if tcp_proc.poll() is None:
|
tcp_proc = self.processes['rtlamr_tcp']
|
||||||
tcp_proc.terminate()
|
if tcp_proc.poll() is None:
|
||||||
del self.processes['rtlamr_tcp']
|
tcp_proc.terminate()
|
||||||
|
del self.processes['rtlamr_tcp']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("RTLAMR reader stopped")
|
logger.info("RTLAMR reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2901,10 +3126,15 @@ class ModeManager:
|
|||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("DSCDecoder not available (missing scipy/numpy)")
|
logger.warning("DSCDecoder not available (missing scipy/numpy)")
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
logger.debug(f"DSC reader stopped: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DSC reader error: {e}")
|
logger.error(f"DSC reader error: {e}")
|
||||||
finally:
|
finally:
|
||||||
proc.wait()
|
try:
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("DSC reader stopped")
|
logger.info("DSC reader stopped")
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -2920,7 +3150,10 @@ class ModeManager:
|
|||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
if not hasattr(self, 'tscm_rf_signals'):
|
if not hasattr(self, 'tscm_rf_signals'):
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
|
if not hasattr(self, 'tscm_wifi_clients'):
|
||||||
|
self.tscm_wifi_clients = {}
|
||||||
self.tscm_anomalies.clear()
|
self.tscm_anomalies.clear()
|
||||||
|
self.tscm_wifi_clients.clear()
|
||||||
|
|
||||||
# Get params for what to scan
|
# Get params for what to scan
|
||||||
scan_wifi = params.get('wifi', True)
|
scan_wifi = params.get('wifi', True)
|
||||||
@@ -2929,6 +3162,7 @@ class ModeManager:
|
|||||||
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
||||||
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
||||||
sdr_device = params.get('sdr_device', params.get('device', 0))
|
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)
|
# Get baseline_id for comparison (same as local mode)
|
||||||
baseline_id = params.get('baseline_id')
|
baseline_id = params.get('baseline_id')
|
||||||
@@ -2938,7 +3172,7 @@ class ModeManager:
|
|||||||
# Start the combined TSCM scanner thread using existing Intercept functions
|
# Start the combined TSCM scanner thread using existing Intercept functions
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._tscm_scanner_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
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -2961,7 +3195,7 @@ class ModeManager:
|
|||||||
|
|
||||||
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
||||||
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
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.
|
"""Combined TSCM scanner using existing Intercept functions.
|
||||||
|
|
||||||
NOTE: This matches local mode behavior exactly:
|
NOTE: This matches local mode behavior exactly:
|
||||||
@@ -2974,9 +3208,18 @@ class ModeManager:
|
|||||||
stop_event = self.stop_events.get(mode)
|
stop_event = self.stop_events.get(mode)
|
||||||
|
|
||||||
# Import existing Intercept TSCM functions
|
# Import existing Intercept TSCM functions
|
||||||
from routes.tscm import _scan_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")
|
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)
|
# Load baseline if specified (same as local mode)
|
||||||
baseline = None
|
baseline = None
|
||||||
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
||||||
@@ -3000,6 +3243,7 @@ class ModeManager:
|
|||||||
|
|
||||||
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
||||||
seen_wifi = {}
|
seen_wifi = {}
|
||||||
|
seen_wifi_clients = {}
|
||||||
seen_bt = {}
|
seen_bt = {}
|
||||||
|
|
||||||
last_rf_scan = 0
|
last_rf_scan = 0
|
||||||
@@ -3050,6 +3294,9 @@ class ModeManager:
|
|||||||
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
||||||
enriched['classification'] = profile.risk_level.value
|
enriched['classification'] = profile.risk_level.value
|
||||||
enriched['score'] = profile.total_score
|
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'] = [
|
enriched['indicators'] = [
|
||||||
{'type': i.type.value, 'desc': i.description}
|
{'type': i.type.value, 'desc': i.description}
|
||||||
for i in profile.indicators
|
for i in profile.indicators
|
||||||
@@ -3057,6 +3304,47 @@ class ModeManager:
|
|||||||
enriched['recommended_action'] = profile.recommended_action
|
enriched['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
self.wifi_networks[bssid] = enriched
|
self.wifi_networks[bssid] = enriched
|
||||||
|
|
||||||
|
# WiFi clients (monitor mode only)
|
||||||
|
try:
|
||||||
|
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
||||||
|
for client in wifi_clients:
|
||||||
|
mac = (client.get('mac') or '').upper()
|
||||||
|
if not mac or mac in seen_wifi_clients:
|
||||||
|
continue
|
||||||
|
seen_wifi_clients[mac] = client
|
||||||
|
|
||||||
|
rssi_val = client.get('rssi_current')
|
||||||
|
if rssi_val is None:
|
||||||
|
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
||||||
|
|
||||||
|
client_device = {
|
||||||
|
'mac': mac,
|
||||||
|
'vendor': client.get('vendor'),
|
||||||
|
'name': client.get('vendor') or 'WiFi Client',
|
||||||
|
'rssi': rssi_val,
|
||||||
|
'associated_bssid': client.get('associated_bssid'),
|
||||||
|
'probed_ssids': client.get('probed_ssids', []),
|
||||||
|
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||||
|
'is_client': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._tscm_correlation:
|
||||||
|
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
||||||
|
client_device['classification'] = profile.risk_level.value
|
||||||
|
client_device['score'] = profile.total_score
|
||||||
|
client_device['score_modifier'] = profile.score_modifier
|
||||||
|
client_device['known_device'] = profile.known_device
|
||||||
|
client_device['known_device_name'] = profile.known_device_name
|
||||||
|
client_device['indicators'] = [
|
||||||
|
{'type': i.type.value, 'desc': i.description}
|
||||||
|
for i in profile.indicators
|
||||||
|
]
|
||||||
|
client_device['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
|
self.tscm_wifi_clients[mac] = client_device
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WiFi client scan error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WiFi scan error: {e}")
|
logger.debug(f"WiFi scan error: {e}")
|
||||||
|
|
||||||
@@ -3096,6 +3384,9 @@ class ModeManager:
|
|||||||
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
||||||
enriched['classification'] = profile.risk_level.value
|
enriched['classification'] = profile.risk_level.value
|
||||||
enriched['score'] = profile.total_score
|
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'] = [
|
enriched['indicators'] = [
|
||||||
{'type': i.type.value, 'desc': i.description}
|
{'type': i.type.value, 'desc': i.description}
|
||||||
for i in profile.indicators
|
for i in profile.indicators
|
||||||
@@ -3111,7 +3402,11 @@ class ModeManager:
|
|||||||
try:
|
try:
|
||||||
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
||||||
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
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
|
# Analyze each RF signal like local mode does
|
||||||
analyzed_signals = []
|
analyzed_signals = []
|
||||||
@@ -3135,6 +3430,9 @@ class ModeManager:
|
|||||||
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
||||||
analyzed['classification'] = profile.risk_level.value
|
analyzed['classification'] = profile.risk_level.value
|
||||||
analyzed['score'] = profile.total_score
|
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'] = [
|
analyzed['indicators'] = [
|
||||||
{'type': i.type.value, 'desc': i.description}
|
{'type': i.type.value, 'desc': i.description}
|
||||||
for i in profile.indicators
|
for i in profile.indicators
|
||||||
@@ -3629,6 +3927,12 @@ class InterceptAgentHandler(BaseHTTPRequestHandler):
|
|||||||
config.push_interval = int(body['push_interval'])
|
config.push_interval = int(body['push_interval'])
|
||||||
self._send_json({'status': 'updated', 'config': config.to_dict()})
|
self._send_json({'status': 'updated', 'config': config.to_dict()})
|
||||||
|
|
||||||
|
elif path == '/wifi/monitor':
|
||||||
|
# Enable/disable monitor mode on WiFi interface
|
||||||
|
result = mode_manager.toggle_monitor_mode(body)
|
||||||
|
status = 200 if result.get('status') == 'success' else 400
|
||||||
|
self._send_json(result, status)
|
||||||
|
|
||||||
elif path.startswith('/') and path.count('/') == 2:
|
elif path.startswith('/') and path.count('/') == 2:
|
||||||
# /{mode}/start or /{mode}/stop
|
# /{mode}/start or /{mode}/stop
|
||||||
parts = path.split('/')
|
parts = path.split('/')
|
||||||
@@ -3794,19 +4098,53 @@ def main():
|
|||||||
print(" Press Ctrl+C to stop")
|
print(" Press Ctrl+C to stop")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Handle shutdown
|
# Shutdown flag
|
||||||
|
shutdown_requested = threading.Event()
|
||||||
|
|
||||||
|
# Handle shutdown - run cleanup in separate thread to avoid blocking
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
|
if shutdown_requested.is_set():
|
||||||
|
# Already shutting down, force exit
|
||||||
|
print("\nForce exit...")
|
||||||
|
os._exit(1)
|
||||||
|
shutdown_requested.set()
|
||||||
print("\nShutting down...")
|
print("\nShutting down...")
|
||||||
# Stop all running modes
|
|
||||||
for mode in list(mode_manager.running_modes.keys()):
|
def cleanup():
|
||||||
mode_manager.stop_mode(mode)
|
# Stop all running modes first (they have subprocesses)
|
||||||
if data_push_loop:
|
for mode in list(mode_manager.running_modes.keys()):
|
||||||
data_push_loop.stop()
|
try:
|
||||||
if push_client:
|
mode_manager.stop_mode(mode)
|
||||||
push_client.stop()
|
except Exception as e:
|
||||||
gps_manager.stop()
|
logger.debug(f"Error stopping {mode}: {e}")
|
||||||
httpd.shutdown()
|
|
||||||
sys.exit(0)
|
# Stop push services
|
||||||
|
if data_push_loop:
|
||||||
|
try:
|
||||||
|
data_push_loop.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if push_client:
|
||||||
|
try:
|
||||||
|
push_client.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stop GPS
|
||||||
|
try:
|
||||||
|
gps_manager.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Shutdown HTTP server
|
||||||
|
try:
|
||||||
|
httpd.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Run cleanup in background thread so signal handler returns quickly
|
||||||
|
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
||||||
|
cleanup_thread.start()
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
@@ -3815,9 +4153,14 @@ def main():
|
|||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
except Exception:
|
||||||
if push_client:
|
pass
|
||||||
push_client.stop()
|
|
||||||
|
# Give cleanup thread time to finish
|
||||||
|
if shutdown_requested.is_set():
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print("Agent stopped.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+14
-3
@@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.12.0"
|
version = "2.18.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = {text = "MIT"}
|
license = {text = "Apache-2.0"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Intercept Contributors"}
|
{name = "Intercept Contributors"}
|
||||||
]
|
]
|
||||||
@@ -14,7 +14,7 @@ classifiers = [
|
|||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Framework :: Flask",
|
"Framework :: Flask",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Operating System :: MacOS",
|
"Operating System :: MacOS",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
@@ -33,6 +33,7 @@ dependencies = [
|
|||||||
"flask-limiter>=2.5.4",
|
"flask-limiter>=2.5.4",
|
||||||
"bleak>=0.21.0",
|
"bleak>=0.21.0",
|
||||||
"flask-sock",
|
"flask-sock",
|
||||||
|
"websocket-client>=1.6.0",
|
||||||
"requests>=2.28.0",
|
"requests>=2.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -52,6 +53,16 @@ dev = [
|
|||||||
"types-flask>=1.1.0",
|
"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]
|
[project.scripts]
|
||||||
intercept = "intercept:main"
|
intercept = "intercept:main"
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -13,23 +13,34 @@ bleak>=0.21.0
|
|||||||
# Satellite tracking (optional - only needed for satellite features)
|
# Satellite tracking (optional - only needed for satellite features)
|
||||||
skyfield>=1.45
|
skyfield>=1.45
|
||||||
|
|
||||||
# DSC decoding (optional - only needed for VHF DSC maritime distress)
|
# DSC decoding and SSTV decoding (DSP pipeline)
|
||||||
scipy>=1.10.0
|
scipy>=1.10.0
|
||||||
numpy>=1.24.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)
|
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
|
||||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||||
meshtastic>=2.0.0
|
meshtastic>=2.0.0
|
||||||
|
|
||||||
|
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||||
|
scapy>=2.4.5
|
||||||
|
|
||||||
# QR code generation for Meshtastic channels (optional)
|
# QR code generation for Meshtastic channels (optional)
|
||||||
qrcode[pil]>=7.4
|
qrcode[pil]>=7.4
|
||||||
|
|
||||||
|
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
|
||||||
|
cryptography>=41.0.0
|
||||||
|
|
||||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||||
# pytest>=7.0.0
|
# pytest>=7.0.0
|
||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
# ruff>=0.1.0
|
# ruff>=0.1.0
|
||||||
# black>=23.0.0
|
# black>=23.0.0
|
||||||
# mypy>=1.0.0
|
# mypy>=1.0.0
|
||||||
|
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||||
flask-sock
|
flask-sock
|
||||||
|
websocket-client>=1.6.0
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ def register_blueprints(app):
|
|||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
from .updater import updater_bp
|
from .updater import updater_bp
|
||||||
from .sstv import sstv_bp
|
from .sstv import sstv_bp
|
||||||
|
from .weather_sat import weather_sat_bp
|
||||||
|
from .sstv_general import sstv_general_bp
|
||||||
|
from .dmr import dmr_bp
|
||||||
|
from .websdr import websdr_bp
|
||||||
|
from .alerts import alerts_bp
|
||||||
|
from .recordings import recordings_bp
|
||||||
|
from .subghz import subghz_bp
|
||||||
|
from .bt_locate import bt_locate_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -51,6 +59,14 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(offline_bp) # Offline mode settings
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
app.register_blueprint(updater_bp) # GitHub update checking
|
app.register_blueprint(updater_bp) # GitHub update checking
|
||||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
|
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||||
|
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||||
|
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||||
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
+81
-7
@@ -20,13 +20,16 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
PROCESS_START_WAIT,
|
PROCESS_START_WAIT,
|
||||||
)
|
)
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
|
||||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||||
|
|
||||||
@@ -43,6 +46,9 @@ DEFAULT_ACARS_FREQUENCIES = [
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
acars_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def find_acarsdec():
|
def find_acarsdec():
|
||||||
"""Find acarsdec binary."""
|
"""Find acarsdec binary."""
|
||||||
@@ -141,9 +147,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
logger.error(f"ACARS stream error: {e}")
|
logger.error(f"ACARS stream error: {e}")
|
||||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
|
global acars_active_device
|
||||||
|
# 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'})
|
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/tools')
|
@acars_bp.route('/tools')
|
||||||
@@ -175,7 +196,7 @@ def acars_status() -> Response:
|
|||||||
@acars_bp.route('/start', methods=['POST'])
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
def start_acars() -> Response:
|
def start_acars() -> Response:
|
||||||
"""Start ACARS decoder."""
|
"""Start ACARS decoder."""
|
||||||
global acars_message_count, acars_last_message_time
|
global acars_message_count, acars_last_message_time, acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
@@ -202,6 +223,18 @@ def start_acars() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
acars_active_device = device_int
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
if isinstance(frequencies, str):
|
if isinstance(frequencies, str):
|
||||||
@@ -218,12 +251,22 @@ def start_acars() -> Response:
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
# Build acarsdec command
|
# Build acarsdec command
|
||||||
# Different forks have different syntax:
|
# Different forks have different syntax:
|
||||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
# 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)
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
cmd = [acarsdec_path]
|
cmd = [acarsdec_path]
|
||||||
if json_flag == '--output':
|
if json_flag == '--output':
|
||||||
@@ -234,21 +277,33 @@ def start_acars() -> Response:
|
|||||||
else:
|
else:
|
||||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||||
|
|
||||||
# Add gain if not auto (must be before -r)
|
# Add gain if not auto (must be before -r/-d)
|
||||||
if gain and str(gain) != '0':
|
if gain and str(gain) != '0':
|
||||||
cmd.extend(['-g', str(gain)])
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
# Add PPM correction if specified (must be before -r)
|
# Add PPM correction if specified (must be before -r/-d)
|
||||||
if ppm and str(ppm) != '0':
|
if ppm and str(ppm) != '0':
|
||||||
cmd.extend(['-p', str(ppm)])
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Add device and frequencies
|
# Add device and frequencies
|
||||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
if is_soapy:
|
||||||
if json_flag == '--output':
|
# 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)
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
cmd.extend(['-m', '256'])
|
cmd.extend(['-m', '256'])
|
||||||
cmd.extend(['--rtlsdr', str(device)])
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
else:
|
else:
|
||||||
|
# TLeconte fork RTL-SDR: -r <device>
|
||||||
cmd.extend(['-r', str(device)])
|
cmd.extend(['-r', str(device)])
|
||||||
cmd.extend(frequencies)
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
@@ -282,7 +337,10 @@ def start_acars() -> Response:
|
|||||||
time.sleep(PROCESS_START_WAIT)
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died
|
# Process died - release device
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
@@ -293,6 +351,7 @@ def start_acars() -> Response:
|
|||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
app_module.acars_process = process
|
app_module.acars_process = process
|
||||||
|
register_process(process)
|
||||||
|
|
||||||
# Start output streaming thread
|
# Start output streaming thread
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
@@ -310,6 +369,10 @@ def start_acars() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -317,6 +380,8 @@ def start_acars() -> Response:
|
|||||||
@acars_bp.route('/stop', methods=['POST'])
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
def stop_acars() -> Response:
|
def stop_acars() -> Response:
|
||||||
"""Stop ACARS decoder."""
|
"""Stop ACARS decoder."""
|
||||||
|
global acars_active_device
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if not app_module.acars_process:
|
if not app_module.acars_process:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -334,6 +399,11 @@ def stop_acars() -> Response:
|
|||||||
|
|
||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device)
|
||||||
|
acars_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -347,6 +417,10 @@ def stream_acars() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('acars', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+74
-11
@@ -33,14 +33,18 @@ from config import (
|
|||||||
ADSB_DB_PASSWORD,
|
ADSB_DB_PASSWORD,
|
||||||
ADSB_DB_PORT,
|
ADSB_DB_PORT,
|
||||||
ADSB_DB_USER,
|
ADSB_DB_USER,
|
||||||
|
ADSB_AUTO_START,
|
||||||
ADSB_HISTORY_ENABLED,
|
ADSB_HISTORY_ENABLED,
|
||||||
|
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
)
|
)
|
||||||
from utils.logging import adsb_logger as logger
|
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 (
|
from utils.validation import (
|
||||||
validate_device_index, validate_gain,
|
validate_device_index, validate_gain,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
ADSB_SBS_PORT,
|
ADSB_SBS_PORT,
|
||||||
@@ -630,6 +634,9 @@ def start_adsb():
|
|||||||
'session': session
|
'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)
|
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||||
existing_service = check_dump1090_service()
|
existing_service = check_dump1090_service()
|
||||||
if existing_service:
|
if existing_service:
|
||||||
@@ -682,8 +689,19 @@ def start_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("Killed stale ADS-B process")
|
logger.info("Killed stale ADS-B process")
|
||||||
|
|
||||||
|
# Check if device is available before starting local dump1090
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'adsb')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Create device object and build command via abstraction layer
|
# Create device object and build command via abstraction layer
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
@@ -708,27 +726,56 @@ def start_adsb():
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
start_new_session=True # Create new process group for clean shutdown
|
start_new_session=True # Create new process group for clean shutdown
|
||||||
)
|
)
|
||||||
|
write_dump1090_pid(app_module.adsb_process.pid)
|
||||||
|
|
||||||
time.sleep(DUMP1090_START_WAIT)
|
time.sleep(DUMP1090_START_WAIT)
|
||||||
|
|
||||||
if app_module.adsb_process.poll() is not None:
|
if app_module.adsb_process.poll() is not None:
|
||||||
# Process exited - try to get error message
|
# Process exited - release device and get error message
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.adsb_process.stderr:
|
if app_module.adsb_process.stderr:
|
||||||
try:
|
try:
|
||||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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.'
|
# Parse stderr to provide specific guidance
|
||||||
if stderr_output:
|
error_type = 'START_FAILED'
|
||||||
error_msg += f' Error: {stderr_output[:200]}'
|
stderr_lower = stderr_output.lower()
|
||||||
return jsonify({'status': 'error', 'message': error_msg})
|
|
||||||
|
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:
|
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.'
|
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.'
|
||||||
if stderr_output:
|
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||||
error_msg += f' Error: {stderr_output[:200]}'
|
|
||||||
return jsonify({'status': 'error', 'message': error_msg})
|
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_using_service = True
|
||||||
adsb_active_device = device # Track which device is being used
|
adsb_active_device = device # Track which device is being used
|
||||||
@@ -750,6 +797,8 @@ def start_adsb():
|
|||||||
'session': session
|
'session': session
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@@ -776,7 +825,13 @@ def stop_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("ADS-B process stopped")
|
logger.info("ADS-B process stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if adsb_active_device is not None:
|
||||||
|
app_module.release_sdr_device(adsb_active_device)
|
||||||
|
|
||||||
adsb_using_service = False
|
adsb_using_service = False
|
||||||
adsb_active_device = None
|
adsb_active_device = None
|
||||||
|
|
||||||
@@ -796,6 +851,10 @@ def stream_adsb():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('adsb', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -812,7 +871,11 @@ def stream_adsb():
|
|||||||
@adsb_bp.route('/dashboard')
|
@adsb_bp.route('/dashboard')
|
||||||
def adsb_dashboard():
|
def adsb_dashboard():
|
||||||
"""Popout ADS-B dashboard."""
|
"""Popout ADS-B dashboard."""
|
||||||
return render_template('adsb_dashboard.html')
|
return render_template(
|
||||||
|
'adsb_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
adsb_auto_start=ADSB_AUTO_START,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/history')
|
@adsb_bp.route('/history')
|
||||||
|
|||||||
+29
-1
@@ -15,9 +15,11 @@ from typing import Generator
|
|||||||
from flask import Blueprint, jsonify, request, Response, render_template
|
from flask import Blueprint, jsonify, request, Response, render_template
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
AIS_TCP_PORT,
|
AIS_TCP_PORT,
|
||||||
@@ -369,6 +371,16 @@ def start_ais():
|
|||||||
app_module.ais_process = None
|
app_module.ais_process = None
|
||||||
logger.info("Killed existing AIS process")
|
logger.info("Killed existing AIS process")
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Build command using SDR abstraction
|
# Build command using SDR abstraction
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
@@ -399,6 +411,8 @@ def start_ais():
|
|||||||
time.sleep(2.0)
|
time.sleep(2.0)
|
||||||
|
|
||||||
if app_module.ais_process.poll() is not None:
|
if app_module.ais_process.poll() is not None:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.ais_process.stderr:
|
if app_module.ais_process.stderr:
|
||||||
try:
|
try:
|
||||||
@@ -424,6 +438,8 @@ def start_ais():
|
|||||||
'port': tcp_port
|
'port': tcp_port
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -447,6 +463,11 @@ def stop_ais():
|
|||||||
pass
|
pass
|
||||||
app_module.ais_process = None
|
app_module.ais_process = None
|
||||||
logger.info("AIS process stopped")
|
logger.info("AIS process stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if ais_active_device is not None:
|
||||||
|
app_module.release_sdr_device(ais_active_device)
|
||||||
|
|
||||||
ais_running = False
|
ais_running = False
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
|
|
||||||
@@ -464,6 +485,10 @@ def stream_ais():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('ais', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -480,4 +505,7 @@ def stream_ais():
|
|||||||
@ais_bp.route('/dashboard')
|
@ais_bp.route('/dashboard')
|
||||||
def ais_dashboard():
|
def ais_dashboard():
|
||||||
"""Popout AIS dashboard."""
|
"""Popout AIS dashboard."""
|
||||||
return render_template('ais_dashboard.html')
|
return render_template(
|
||||||
|
'ais_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
+124
-35
@@ -13,7 +13,7 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from subprocess import DEVNULL, PIPE, STDOUT
|
from subprocess import PIPE, STDOUT
|
||||||
from typing import Generator, Optional
|
from typing import Generator, Optional
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
@@ -22,6 +22,8 @@ import app as app_module
|
|||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -31,6 +33,9 @@ from utils.constants import (
|
|||||||
|
|
||||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
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 by region (MHz)
|
||||||
APRS_FREQUENCIES = {
|
APRS_FREQUENCIES = {
|
||||||
'north_america': '144.390',
|
'north_america': '144.390',
|
||||||
@@ -49,6 +54,7 @@ aprs_packet_count = 0
|
|||||||
aprs_station_count = 0
|
aprs_station_count = 0
|
||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {} # callsign -> station data
|
aprs_stations = {} # callsign -> station data
|
||||||
|
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
|
||||||
|
|
||||||
# Meter rate limiting
|
# Meter rate limiting
|
||||||
_last_meter_time = 0.0
|
_last_meter_time = 0.0
|
||||||
@@ -72,6 +78,11 @@ def find_rtl_fm() -> Optional[str]:
|
|||||||
return shutil.which('rtl_fm')
|
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]:
|
def find_rtl_power() -> Optional[str]:
|
||||||
"""Find rtl_power binary for spectrum scanning."""
|
"""Find rtl_power binary for spectrum scanning."""
|
||||||
return shutil.which('rtl_power')
|
return shutil.which('rtl_power')
|
||||||
@@ -1301,7 +1312,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
|
|
||||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
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:
|
Outputs two types of messages to the queue:
|
||||||
- type='aprs': Decoded APRS packets
|
- type='aprs': Decoded APRS packets
|
||||||
@@ -1367,6 +1378,13 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
'last_seen': packet.get('timestamp'),
|
'last_seen': packet.get('timestamp'),
|
||||||
'packet_type': packet.get('packet_type'),
|
'packet_type': packet.get('packet_type'),
|
||||||
}
|
}
|
||||||
|
# Evict oldest stations when limit is exceeded
|
||||||
|
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||||
|
oldest = min(
|
||||||
|
aprs_stations,
|
||||||
|
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||||
|
)
|
||||||
|
del aprs_stations[oldest]
|
||||||
|
|
||||||
app_module.aprs_queue.put(packet)
|
app_module.aprs_queue.put(packet)
|
||||||
|
|
||||||
@@ -1383,6 +1401,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
logger.error(f"APRS stream error: {e}")
|
logger.error(f"APRS stream error: {e}")
|
||||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
|
global aprs_active_device
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
# Cleanup processes
|
# Cleanup processes
|
||||||
for proc in [rtl_process, decoder_process]:
|
for proc in [rtl_process, decoder_process]:
|
||||||
@@ -1394,20 +1413,27 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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')
|
@aprs_bp.route('/tools')
|
||||||
def check_aprs_tools() -> Response:
|
def check_aprs_tools() -> Response:
|
||||||
"""Check for APRS decoding tools."""
|
"""Check for APRS decoding tools."""
|
||||||
has_rtl_fm = find_rtl_fm() is not None
|
has_rtl_fm = find_rtl_fm() is not None
|
||||||
|
has_rx_fm = find_rx_fm() is not None
|
||||||
has_direwolf = find_direwolf() is not None
|
has_direwolf = find_direwolf() is not None
|
||||||
has_multimon = find_multimon_ng() is not None
|
has_multimon = find_multimon_ng() is not None
|
||||||
|
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'rtl_fm': has_rtl_fm,
|
'rtl_fm': has_rtl_fm,
|
||||||
|
'rx_fm': has_rx_fm,
|
||||||
'direwolf': has_direwolf,
|
'direwolf': has_direwolf,
|
||||||
'multimon_ng': has_multimon,
|
'multimon_ng': has_multimon,
|
||||||
'ready': has_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)
|
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1441,6 +1467,7 @@ def get_stations() -> Response:
|
|||||||
def start_aprs() -> Response:
|
def start_aprs() -> Response:
|
||||||
"""Start APRS decoder."""
|
"""Start APRS decoder."""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
|
global aprs_active_device
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||||
@@ -1449,14 +1476,6 @@ def start_aprs() -> Response:
|
|||||||
'message': 'APRS decoder already running'
|
'message': 'APRS decoder already running'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
# Check for required tools
|
|
||||||
rtl_fm_path = find_rtl_fm()
|
|
||||||
if not rtl_fm_path:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||||
direwolf_path = find_direwolf()
|
direwolf_path = find_direwolf()
|
||||||
multimon_path = find_multimon_ng()
|
multimon_path = find_multimon_ng()
|
||||||
@@ -1477,6 +1496,35 @@ def start_aprs() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
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
|
# Get frequency for region
|
||||||
region = data.get('region', 'north_america')
|
region = data.get('region', 'north_america')
|
||||||
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||||
@@ -1497,28 +1545,29 @@ def start_aprs() -> Response:
|
|||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {}
|
aprs_stations = {}
|
||||||
|
|
||||||
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
|
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||||
freq_hz = f"{float(frequency)}M"
|
try:
|
||||||
rtl_cmd = [
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
rtl_fm_path,
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
'-f', freq_hz,
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
'-M', 'nfm', # Narrowband FM for APRS
|
device=sdr_device,
|
||||||
'-s', '22050', # Sample rate matching direwolf -r 22050
|
frequency_mhz=float(frequency),
|
||||||
'-E', 'dc', # Enable DC blocking filter for cleaner audio
|
sample_rate=22050,
|
||||||
'-A', 'fast', # Fast AGC for packet bursts
|
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||||
'-d', str(device),
|
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 sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||||
if gain and str(gain) != '0':
|
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||||
rtl_cmd.extend(['-g', str(gain)])
|
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||||
|
except Exception as e:
|
||||||
# PPM frequency correction
|
if aprs_active_device is not None:
|
||||||
if ppm and str(ppm) != '0':
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
rtl_cmd.extend(['-p', str(ppm)])
|
aprs_active_device = None
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||||
# Output raw audio to stdout
|
|
||||||
rtl_cmd.append('-')
|
|
||||||
|
|
||||||
# Build decoder command
|
# Build decoder command
|
||||||
if direwolf_path:
|
if direwolf_path:
|
||||||
@@ -1552,15 +1601,25 @@ def start_aprs() -> Response:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Start rtl_fm with stdout piped to decoder.
|
# 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.
|
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
|
||||||
rtl_process = subprocess.Popen(
|
rtl_process = subprocess.Popen(
|
||||||
rtl_cmd,
|
rtl_cmd,
|
||||||
stdout=PIPE,
|
stdout=PIPE,
|
||||||
stderr=DEVNULL,
|
stderr=PIPE,
|
||||||
start_new_session=True
|
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.
|
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||||
# Use text mode with line buffering for reliable line-by-line reading.
|
# Use text mode with line buffering for reliable line-by-line reading.
|
||||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||||
@@ -1582,13 +1641,25 @@ def start_aprs() -> Response:
|
|||||||
time.sleep(PROCESS_START_WAIT)
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
if rtl_process.poll() is not None:
|
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})'
|
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)
|
logger.error(error_msg)
|
||||||
try:
|
try:
|
||||||
decoder_process.kill()
|
decoder_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
|
aprs_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
if decoder_process.poll() is not None:
|
if decoder_process.poll() is not None:
|
||||||
@@ -1602,6 +1673,9 @@ def start_aprs() -> Response:
|
|||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
|
aprs_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
# Store references for status checks and cleanup
|
# Store references for status checks and cleanup
|
||||||
@@ -1621,17 +1695,23 @@ def start_aprs() -> Response:
|
|||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'region': region,
|
'region': region,
|
||||||
'device': device,
|
'device': device,
|
||||||
|
'sdr_type': sdr_type.value,
|
||||||
'decoder': decoder_name
|
'decoder': decoder_name
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start APRS decoder: {e}")
|
logger.error(f"Failed to start APRS decoder: {e}")
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
|
aprs_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/stop', methods=['POST'])
|
@aprs_bp.route('/stop', methods=['POST'])
|
||||||
def stop_aprs() -> Response:
|
def stop_aprs() -> Response:
|
||||||
"""Stop APRS decoder."""
|
"""Stop APRS decoder."""
|
||||||
|
global aprs_active_device
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
processes_to_stop = []
|
processes_to_stop = []
|
||||||
|
|
||||||
@@ -1660,6 +1740,11 @@ def stop_aprs() -> Response:
|
|||||||
if hasattr(app_module, 'aprs_rtl_process'):
|
if hasattr(app_module, 'aprs_rtl_process'):
|
||||||
app_module.aprs_rtl_process = None
|
app_module.aprs_rtl_process = None
|
||||||
|
|
||||||
|
# Release SDR device
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
|
aprs_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -1673,6 +1758,10 @@ def stream_aprs() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('aprs', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+30
-10
@@ -1,10 +1,11 @@
|
|||||||
"""WebSocket-based audio streaming for SDR."""
|
"""WebSocket-based audio streaming for SDR."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# Try to import flask-sock
|
# Try to import flask-sock
|
||||||
@@ -40,6 +41,12 @@ def find_ffmpeg():
|
|||||||
return shutil.which('ffmpeg')
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def _rtl_fm_demod_mode(modulation):
|
||||||
|
"""Map UI modulation names to rtl_fm demod tokens."""
|
||||||
|
mod = str(modulation or '').lower().strip()
|
||||||
|
return 'wbfm' if mod == 'wfm' else mod
|
||||||
|
|
||||||
|
|
||||||
def kill_audio_processes():
|
def kill_audio_processes():
|
||||||
"""Kill any running audio processes."""
|
"""Kill any running audio processes."""
|
||||||
global audio_process, rtl_process
|
global audio_process, rtl_process
|
||||||
@@ -66,12 +73,6 @@ def kill_audio_processes():
|
|||||||
pass
|
pass
|
||||||
rtl_process = None
|
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)
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ def start_audio_stream(config):
|
|||||||
|
|
||||||
rtl_cmd = [
|
rtl_cmd = [
|
||||||
rtl_fm,
|
rtl_fm,
|
||||||
'-M', mod,
|
'-M', _rtl_fm_demod_mode(mod),
|
||||||
'-f', str(freq_hz),
|
'-f', str(freq_hz),
|
||||||
'-s', str(sample_rate),
|
'-s', str(sample_rate),
|
||||||
'-r', str(resample_rate),
|
'-r', str(resample_rate),
|
||||||
@@ -229,7 +230,11 @@ def init_audio_websocket(app: Flask):
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
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}")
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
|
||||||
# Stream audio data if active
|
# Stream audio data if active
|
||||||
@@ -253,4 +258,19 @@ def init_audio_websocket(app: Flask):
|
|||||||
finally:
|
finally:
|
||||||
with process_lock:
|
with process_lock:
|
||||||
kill_audio_processes()
|
kill_audio_processes()
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream.
|
||||||
|
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")
|
logger.info("WebSocket audio client disconnected")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import app as app_module
|
|||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||||
@@ -563,6 +564,10 @@ def stream_bt():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
msg = app_module.bt_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+106
-4
@@ -11,6 +11,8 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
@@ -28,12 +30,18 @@ from utils.bluetooth import (
|
|||||||
)
|
)
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
# Blueprint
|
# Blueprint
|
||||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||||
|
|
||||||
|
# Seen-before tracking
|
||||||
|
_bt_seen_cache: set[str] = set()
|
||||||
|
_bt_session_seen: set[str] = set()
|
||||||
|
_bt_seen_lock = threading.Lock()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE FUNCTIONS
|
# DATABASE FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -173,6 +181,13 @@ def save_observation_history(device: BTDeviceAggregate) -> None:
|
|||||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||||
|
|
||||||
|
|
||||||
|
def load_seen_device_ids() -> set[str]:
|
||||||
|
"""Load distinct device IDs from history for seen-before tracking."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
||||||
|
return {row['device_id'] for row in cursor}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API ENDPOINTS
|
# API ENDPOINTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -214,13 +229,35 @@ def start_scan():
|
|||||||
rssi_threshold = data.get('rssi_threshold', -100)
|
rssi_threshold = data.get('rssi_threshold', -100)
|
||||||
|
|
||||||
# Validate mode
|
# Validate mode
|
||||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||||
if mode not in valid_modes:
|
if mode not in valid_modes:
|
||||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||||
|
|
||||||
# Get scanner instance
|
# Get scanner instance
|
||||||
scanner = get_bluetooth_scanner(adapter_id)
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
|
|
||||||
|
# Initialize database tables if needed
|
||||||
|
init_bt_tables()
|
||||||
|
|
||||||
|
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
||||||
|
try:
|
||||||
|
with _bt_seen_lock:
|
||||||
|
device.seen_before = device.device_id in _bt_seen_cache
|
||||||
|
if device.device_id not in _bt_session_seen:
|
||||||
|
save_observation_history(device)
|
||||||
|
_bt_session_seen.add(device.device_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"BT seen-before update failed: {e}")
|
||||||
|
|
||||||
|
# Setup seen-before callback
|
||||||
|
if _handle_seen_before not in scanner._on_device_updated_callbacks:
|
||||||
|
scanner.add_device_callback(_handle_seen_before)
|
||||||
|
|
||||||
|
# Ensure cache is initialized
|
||||||
|
with _bt_seen_lock:
|
||||||
|
if not _bt_seen_cache:
|
||||||
|
_bt_seen_cache.update(load_seen_device_ids())
|
||||||
|
|
||||||
# Check if already scanning
|
# Check if already scanning
|
||||||
if scanner.is_scanning:
|
if scanner.is_scanning:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -228,8 +265,11 @@ def start_scan():
|
|||||||
'scan_status': scanner.get_status().to_dict()
|
'scan_status': scanner.get_status().to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Initialize database tables if needed
|
# Refresh seen-before cache and reset session set for a new scan
|
||||||
init_bt_tables()
|
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
|
# Load active baseline if exists
|
||||||
baseline_id = get_active_baseline_id()
|
baseline_id = get_active_baseline_id()
|
||||||
@@ -860,6 +900,10 @@ def stream_events():
|
|||||||
"""Generate SSE events from scanner."""
|
"""Generate SSE events from scanner."""
|
||||||
for event in scanner.stream_events(timeout=1.0):
|
for event in scanner.stream_events(timeout=1.0):
|
||||||
event_name, event_data = map_event_type(event)
|
event_name, event_data = map_event_type(event)
|
||||||
|
try:
|
||||||
|
process_event('bluetooth', event_data, event_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(event_data, event=event_name)
|
yield format_sse(event_data, event=event_name)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@@ -947,6 +991,17 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
# Convert to TSCM format with tracker detection data
|
# Convert to TSCM format with tracker detection data
|
||||||
tscm_devices = []
|
tscm_devices = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
|
manufacturer_name = device.manufacturer_name
|
||||||
|
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
||||||
|
if device.address and not device.is_randomized_mac:
|
||||||
|
try:
|
||||||
|
from data.oui import get_manufacturer
|
||||||
|
oui_vendor = get_manufacturer(device.address)
|
||||||
|
if oui_vendor and oui_vendor != 'Unknown':
|
||||||
|
manufacturer_name = oui_vendor
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
device_data = {
|
device_data = {
|
||||||
'mac': device.address,
|
'mac': device.address,
|
||||||
'address_type': device.address_type,
|
'address_type': device.address_type,
|
||||||
@@ -956,7 +1011,7 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
'rssi_median': device.rssi_median,
|
'rssi_median': device.rssi_median,
|
||||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||||
'type': _classify_device_type(device),
|
'type': _classify_device_type(device),
|
||||||
'manufacturer': device.manufacturer_name,
|
'manufacturer': manufacturer_name,
|
||||||
'manufacturer_id': device.manufacturer_id,
|
'manufacturer_id': device.manufacturer_id,
|
||||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||||
'protocol': device.protocol,
|
'protocol': device.protocol,
|
||||||
@@ -1178,6 +1233,30 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
|||||||
"""Classify device type from available data."""
|
"""Classify device type from available data."""
|
||||||
name_lower = (device.name or '').lower()
|
name_lower = (device.name or '').lower()
|
||||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||||
|
service_uuids = device.service_uuids or []
|
||||||
|
|
||||||
|
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
||||||
|
if device.address and not device.is_randomized_mac:
|
||||||
|
try:
|
||||||
|
from data.oui import get_manufacturer
|
||||||
|
oui_vendor = get_manufacturer(device.address)
|
||||||
|
if oui_vendor and oui_vendor != 'Unknown':
|
||||||
|
manufacturer_lower = oui_vendor.lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def normalize_uuid(uuid: str) -> str:
|
||||||
|
if not uuid:
|
||||||
|
return ''
|
||||||
|
value = str(uuid).lower().strip()
|
||||||
|
if value.startswith('0x'):
|
||||||
|
value = value[2:]
|
||||||
|
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
||||||
|
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
||||||
|
return value[4:8]
|
||||||
|
if len(value) == 4:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
# Check by name patterns
|
# Check by name patterns
|
||||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||||
@@ -1197,6 +1276,29 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
|||||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||||
return 'media'
|
return 'media'
|
||||||
|
|
||||||
|
# Tracker signals (metadata or Find My service)
|
||||||
|
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
||||||
|
return 'tracker'
|
||||||
|
|
||||||
|
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
||||||
|
if 'fd6f' in normalized_uuids:
|
||||||
|
return 'tracker'
|
||||||
|
|
||||||
|
# Service UUIDs (GATT / classic)
|
||||||
|
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
||||||
|
wearable_uuids = {'180d', '1814', '1816'}
|
||||||
|
hid_uuids = {'1812'}
|
||||||
|
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
||||||
|
|
||||||
|
if normalized_uuids & audio_uuids:
|
||||||
|
return 'audio'
|
||||||
|
if normalized_uuids & hid_uuids:
|
||||||
|
return 'peripheral'
|
||||||
|
if normalized_uuids & wearable_uuids:
|
||||||
|
return 'wearable'
|
||||||
|
if normalized_uuids & beacon_uuids:
|
||||||
|
return 'beacon'
|
||||||
|
|
||||||
# Check by manufacturer
|
# Check by manufacturer
|
||||||
if 'apple' in manufacturer_lower:
|
if 'apple' in manufacturer_lower:
|
||||||
return 'apple_device'
|
return 'apple_device'
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
BT Locate — Bluetooth SAR Device Location Flask Blueprint.
|
||||||
|
|
||||||
|
Provides endpoints for managing locate sessions, streaming detection events,
|
||||||
|
and retrieving GPS-tagged signal trails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.bluetooth.irk_extractor import get_paired_irks
|
||||||
|
from utils.bt_locate import (
|
||||||
|
Environment,
|
||||||
|
LocateTarget,
|
||||||
|
get_locate_session,
|
||||||
|
resolve_rpa,
|
||||||
|
start_locate_session,
|
||||||
|
stop_locate_session,
|
||||||
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.bt_locate')
|
||||||
|
|
||||||
|
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/start', methods=['POST'])
|
||||||
|
def start_session():
|
||||||
|
"""
|
||||||
|
Start a locate session.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- mac_address: Target MAC address (optional)
|
||||||
|
- name_pattern: Target name substring (optional)
|
||||||
|
- irk_hex: Identity Resolving Key hex string (optional)
|
||||||
|
- device_id: Device ID from Bluetooth scanner (optional)
|
||||||
|
- known_name: Hand-off device name (optional)
|
||||||
|
- known_manufacturer: Hand-off manufacturer (optional)
|
||||||
|
- last_known_rssi: Hand-off last RSSI (optional)
|
||||||
|
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||||
|
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with session status.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
# Build target
|
||||||
|
target = LocateTarget(
|
||||||
|
mac_address=data.get('mac_address'),
|
||||||
|
name_pattern=data.get('name_pattern'),
|
||||||
|
irk_hex=data.get('irk_hex'),
|
||||||
|
device_id=data.get('device_id'),
|
||||||
|
known_name=data.get('known_name'),
|
||||||
|
known_manufacturer=data.get('known_manufacturer'),
|
||||||
|
last_known_rssi=data.get('last_known_rssi'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# At least one identifier required
|
||||||
|
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]):
|
||||||
|
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
|
||||||
|
|
||||||
|
# Parse environment
|
||||||
|
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||||
|
try:
|
||||||
|
environment = Environment[env_str]
|
||||||
|
except KeyError:
|
||||||
|
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||||
|
|
||||||
|
custom_exponent = data.get('custom_exponent')
|
||||||
|
if custom_exponent is not None:
|
||||||
|
try:
|
||||||
|
custom_exponent = float(custom_exponent)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'error': 'custom_exponent must be a number'}), 400
|
||||||
|
|
||||||
|
# Fallback coordinates when GPS is unavailable (from user settings)
|
||||||
|
fallback_lat = None
|
||||||
|
fallback_lon = None
|
||||||
|
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
|
||||||
|
try:
|
||||||
|
fallback_lat = float(data['fallback_lat'])
|
||||||
|
fallback_lon = float(data['fallback_lon'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting locate session: target={target.to_dict()}, "
|
||||||
|
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||||
|
)
|
||||||
|
|
||||||
|
session = start_locate_session(
|
||||||
|
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'session': session.get_status(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_session():
|
||||||
|
"""Stop the active locate session."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'no_session'})
|
||||||
|
|
||||||
|
stop_locate_session()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/status', methods=['GET'])
|
||||||
|
def get_status():
|
||||||
|
"""Get locate session status."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({
|
||||||
|
'active': False,
|
||||||
|
'target': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(session.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||||
|
def get_trail():
|
||||||
|
"""Get detection trail data."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'trail': [], 'gps_trail': []})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'trail': session.get_trail(),
|
||||||
|
'gps_trail': session.get_gps_trail(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/stream', methods=['GET'])
|
||||||
|
def stream_detections():
|
||||||
|
"""SSE stream of detection events."""
|
||||||
|
|
||||||
|
def event_generator() -> Generator[str, None, None]:
|
||||||
|
while True:
|
||||||
|
# Re-fetch session each iteration in case it changes
|
||||||
|
s = get_locate_session()
|
||||||
|
if not s:
|
||||||
|
yield format_sse({'type': 'session_ended'}, event='session_ended')
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = s.event_queue.get(timeout=2.0)
|
||||||
|
yield format_sse(event, event='detection')
|
||||||
|
except Exception:
|
||||||
|
yield format_sse({}, event='ping')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
event_generator(),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
|
||||||
|
def test_resolve_rpa():
|
||||||
|
"""
|
||||||
|
Test if an IRK resolves to a given address.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- irk_hex: 16-byte IRK as hex string
|
||||||
|
- address: BLE address string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with resolution result.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
irk_hex = data.get('irk_hex', '')
|
||||||
|
address = data.get('address', '')
|
||||||
|
|
||||||
|
if not irk_hex or not address:
|
||||||
|
return jsonify({'error': 'irk_hex and address are required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
irk = bytes.fromhex(irk_hex)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
||||||
|
|
||||||
|
if len(irk) != 16:
|
||||||
|
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400
|
||||||
|
|
||||||
|
result = resolve_rpa(irk, address)
|
||||||
|
return jsonify({
|
||||||
|
'resolved': result,
|
||||||
|
'irk_hex': irk_hex,
|
||||||
|
'address': address,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/environment', methods=['POST'])
|
||||||
|
def set_environment():
|
||||||
|
"""Update the environment on the active session."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'error': 'no active session'}), 400
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
env_str = data.get('environment', '').upper()
|
||||||
|
try:
|
||||||
|
environment = Environment[env_str]
|
||||||
|
except KeyError:
|
||||||
|
return jsonify({'error': f'Invalid environment: {env_str}'}), 400
|
||||||
|
|
||||||
|
custom_exponent = data.get('custom_exponent')
|
||||||
|
if custom_exponent is not None:
|
||||||
|
try:
|
||||||
|
custom_exponent = float(custom_exponent)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
custom_exponent = None
|
||||||
|
|
||||||
|
session.set_environment(environment, custom_exponent)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'updated',
|
||||||
|
'environment': environment.name,
|
||||||
|
'path_loss_exponent': session.estimator.n,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/debug', methods=['GET'])
|
||||||
|
def debug_matching():
|
||||||
|
"""Debug endpoint showing scanner devices and match results."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'error': 'no session'})
|
||||||
|
|
||||||
|
scanner = session._scanner
|
||||||
|
if not scanner:
|
||||||
|
return jsonify({'error': 'no scanner'})
|
||||||
|
|
||||||
|
devices = scanner.get_devices(max_age_seconds=30)
|
||||||
|
return jsonify({
|
||||||
|
'target': session.target.to_dict(),
|
||||||
|
'device_count': len(devices),
|
||||||
|
'devices': [
|
||||||
|
{
|
||||||
|
'device_id': d.device_id,
|
||||||
|
'address': d.address,
|
||||||
|
'name': d.name,
|
||||||
|
'rssi': d.rssi_current,
|
||||||
|
'matches': session.target.matches(d),
|
||||||
|
}
|
||||||
|
for d in devices
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/paired_irks', methods=['GET'])
|
||||||
|
def paired_irks():
|
||||||
|
"""Return paired Bluetooth devices that have IRKs."""
|
||||||
|
try:
|
||||||
|
devices = get_paired_irks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to read paired IRKs")
|
||||||
|
return jsonify({'devices': [], 'error': str(e)})
|
||||||
|
|
||||||
|
return jsonify({'devices': devices})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/clear_trail', methods=['POST'])
|
||||||
|
def clear_trail():
|
||||||
|
"""Clear the detection trail."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'no_session'})
|
||||||
|
|
||||||
|
session.clear_trail()
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
+109
-1
@@ -17,6 +17,8 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.database import (
|
from utils.database import (
|
||||||
@@ -91,6 +93,17 @@ def register_agent():
|
|||||||
if not base_url:
|
if not base_url:
|
||||||
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
try:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
||||||
|
if not parsed.netloc:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||||
|
|
||||||
# Check if agent already exists
|
# Check if agent already exists
|
||||||
existing = get_agent_by_name(name)
|
existing = get_agent_by_name(name)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -128,9 +141,12 @@ def register_agent():
|
|||||||
update_agent(agent_id, update_last_seen=True)
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
agent = get_agent(agent_id)
|
agent = get_agent(agent_id)
|
||||||
|
message = 'Agent registered successfully'
|
||||||
|
if capabilities is None:
|
||||||
|
message += ' (could not connect - agent may be offline)'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': 'Agent registered successfully',
|
'message': message,
|
||||||
'agent': agent
|
'agent': agent
|
||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
@@ -466,6 +482,98 @@ def proxy_mode_data(agent_id: int, mode: str):
|
|||||||
}), 502
|
}), 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 {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.post('/wifi/monitor', data)
|
||||||
|
|
||||||
|
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
|
||||||
|
if result.get('status') == 'success':
|
||||||
|
try:
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
if metadata.get('healthy'):
|
||||||
|
caps = metadata.get('capabilities') or {}
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-fatal if refresh fails
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': result.get('status', 'error'),
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'monitor_interface': result.get('monitor_interface'),
|
||||||
|
'message': result.get('message')
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Push Data Ingestion
|
# Push Data Ingestion
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+758
@@ -0,0 +1,758 @@
|
|||||||
|
"""DMR / P25 / Digital Voice decoding routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.constants import (
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
QUEUE_MAX_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.dmr')
|
||||||
|
|
||||||
|
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# GLOBAL STATE
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
dmr_rtl_process: Optional[subprocess.Popen] = None
|
||||||
|
dmr_dsd_process: Optional[subprocess.Popen] = None
|
||||||
|
dmr_thread: Optional[threading.Thread] = None
|
||||||
|
dmr_running = False
|
||||||
|
dmr_has_audio = False # True when ffmpeg available and dsd outputs audio
|
||||||
|
dmr_lock = threading.Lock()
|
||||||
|
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
dmr_active_device: Optional[int] = None
|
||||||
|
|
||||||
|
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
|
||||||
|
# active ffmpeg stdin sinks when streaming clients are connected.
|
||||||
|
# This prevents dsd-fme from blocking on stdout (which would also
|
||||||
|
# freeze stderr / text data output).
|
||||||
|
_ffmpeg_sinks: set[object] = set()
|
||||||
|
_ffmpeg_sinks_lock = threading.Lock()
|
||||||
|
|
||||||
|
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||||
|
|
||||||
|
# Classic dsd flags
|
||||||
|
_DSD_PROTOCOL_FLAGS = {
|
||||||
|
'auto': [],
|
||||||
|
'dmr': ['-fd'],
|
||||||
|
'p25': ['-fp'],
|
||||||
|
'nxdn': ['-fn'],
|
||||||
|
'dstar': ['-fi'],
|
||||||
|
'provoice': ['-fv'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# dsd-fme remapped several flags from classic DSD:
|
||||||
|
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
|
||||||
|
# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25),
|
||||||
|
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
|
||||||
|
# -ft = XDMA multi-protocol decoder
|
||||||
|
_DSD_FME_PROTOCOL_FLAGS = {
|
||||||
|
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
|
||||||
|
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||||
|
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
|
||||||
|
'nxdn': ['-fn'], # NXDN96
|
||||||
|
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
|
||||||
|
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modulation hints: force C4FM for protocols that use it, improving
|
||||||
|
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||||
|
_DSD_FME_MODULATION = {
|
||||||
|
'dmr': ['-mc'], # C4FM
|
||||||
|
'nxdn': ['-mc'], # C4FM
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# HELPERS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
|
||||||
|
def find_dsd() -> tuple[str | None, bool]:
|
||||||
|
"""Find DSD (Digital Speech Decoder) binary.
|
||||||
|
|
||||||
|
Checks for dsd-fme first (common fork), then falls back to dsd.
|
||||||
|
Returns (path, is_fme) tuple.
|
||||||
|
"""
|
||||||
|
path = shutil.which('dsd-fme')
|
||||||
|
if path:
|
||||||
|
return path, True
|
||||||
|
path = shutil.which('dsd')
|
||||||
|
if path:
|
||||||
|
return path, False
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def find_rtl_fm() -> str | None:
|
||||||
|
"""Find rtl_fm binary."""
|
||||||
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def find_rx_fm() -> str | None:
|
||||||
|
"""Find SoapySDR rx_fm binary."""
|
||||||
|
return shutil.which('rx_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def find_ffmpeg() -> str | None:
|
||||||
|
"""Find ffmpeg for audio encoding."""
|
||||||
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dsd_output(line: str) -> dict | None:
|
||||||
|
"""Parse a line of DSD stderr output into a structured event.
|
||||||
|
|
||||||
|
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
|
||||||
|
different formatting for talkgroup / source / voice frame lines.
|
||||||
|
"""
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
|
||||||
|
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
|
||||||
|
# characters (│, ─) as column separators in DATA lines, so we must not
|
||||||
|
# discard lines that also contain alphanumeric content.
|
||||||
|
stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line)
|
||||||
|
if not stripped_of_box:
|
||||||
|
return None
|
||||||
|
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
||||||
|
return None
|
||||||
|
|
||||||
|
ts = datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
|
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||||
|
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
||||||
|
if sync_match:
|
||||||
|
return {
|
||||||
|
'type': 'sync',
|
||||||
|
'protocol': sync_match.group(1).strip(),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
|
||||||
|
# is captured as a call event rather than a bare slot event.
|
||||||
|
# Classic dsd: "TG: 12345 Src: 67890"
|
||||||
|
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
|
||||||
|
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
|
||||||
|
tg_match = re.search(
|
||||||
|
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if tg_match:
|
||||||
|
result = {
|
||||||
|
'type': 'call',
|
||||||
|
'talkgroup': int(tg_match.group(1)),
|
||||||
|
'source_id': int(tg_match.group(2)),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
# Extract slot if present on the same line
|
||||||
|
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||||
|
if slot_inline:
|
||||||
|
result['slot'] = int(slot_inline.group(1))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# P25 NAC (Network Access Code) — check before voice/slot
|
||||||
|
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
|
||||||
|
if nac_match:
|
||||||
|
return {
|
||||||
|
'type': 'nac',
|
||||||
|
'nac': nac_match.group(1),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Voice frame detection — check BEFORE bare slot match
|
||||||
|
# Classic dsd: "Voice" keyword in frame lines
|
||||||
|
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
|
||||||
|
if re.search(r'\bvoice\b', line, re.IGNORECASE):
|
||||||
|
result = {
|
||||||
|
'type': 'voice',
|
||||||
|
'detail': line,
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||||
|
if slot_inline:
|
||||||
|
result['slot'] = int(slot_inline.group(1))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Bare slot info (only when line is *just* slot info, not voice/call)
|
||||||
|
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
|
||||||
|
if slot_match:
|
||||||
|
return {
|
||||||
|
'type': 'slot',
|
||||||
|
'slot': int(slot_match.group(1)),
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
|
||||||
|
# Also catches "Closing", "Input", and other lifecycle lines.
|
||||||
|
# Forward as raw so the frontend can show decoder is alive.
|
||||||
|
return {
|
||||||
|
'type': 'raw',
|
||||||
|
'text': line[:200],
|
||||||
|
'timestamp': ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
|
||||||
|
|
||||||
|
# 100ms of silence at 8kHz 16-bit mono = 1600 bytes
|
||||||
|
_SILENCE_CHUNK = b'\x00' * 1600
|
||||||
|
|
||||||
|
|
||||||
|
def _register_audio_sink(sink: object) -> None:
|
||||||
|
"""Register an ffmpeg stdin sink for mux fanout."""
|
||||||
|
with _ffmpeg_sinks_lock:
|
||||||
|
_ffmpeg_sinks.add(sink)
|
||||||
|
|
||||||
|
|
||||||
|
def _unregister_audio_sink(sink: object) -> None:
|
||||||
|
"""Remove an ffmpeg stdin sink from mux fanout."""
|
||||||
|
with _ffmpeg_sinks_lock:
|
||||||
|
_ffmpeg_sinks.discard(sink)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_audio_sinks() -> tuple[object, ...]:
|
||||||
|
"""Snapshot current audio sinks for lock-free iteration."""
|
||||||
|
with _ffmpeg_sinks_lock:
|
||||||
|
return tuple(_ffmpeg_sinks)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
|
||||||
|
"""Terminate and unregister a subprocess if present."""
|
||||||
|
if not proc:
|
||||||
|
return
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(proc)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_runtime_state(*, release_device: bool) -> None:
|
||||||
|
"""Reset process + runtime state and optionally release SDR ownership."""
|
||||||
|
global dmr_rtl_process, dmr_dsd_process
|
||||||
|
global dmr_running, dmr_has_audio, dmr_active_device
|
||||||
|
|
||||||
|
_stop_process(dmr_dsd_process)
|
||||||
|
_stop_process(dmr_rtl_process)
|
||||||
|
dmr_rtl_process = None
|
||||||
|
dmr_dsd_process = None
|
||||||
|
dmr_running = False
|
||||||
|
dmr_has_audio = False
|
||||||
|
with _ffmpeg_sinks_lock:
|
||||||
|
_ffmpeg_sinks.clear()
|
||||||
|
|
||||||
|
if release_device and dmr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
|
dmr_active_device = None
|
||||||
|
|
||||||
|
|
||||||
|
def _dsd_audio_mux(dsd_stdout):
|
||||||
|
"""Mux thread: sole reader of dsd-fme stdout.
|
||||||
|
|
||||||
|
Always drains dsd-fme's audio output to prevent the process from
|
||||||
|
blocking on stdout writes (which would also freeze stderr / text
|
||||||
|
data). When streaming clients are connected, forwards data to all
|
||||||
|
active ffmpeg stdin sinks with silence fill during voice gaps.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while dmr_running:
|
||||||
|
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
|
||||||
|
if ready:
|
||||||
|
data = os.read(dsd_stdout.fileno(), 4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
sinks = _get_audio_sinks()
|
||||||
|
for sink in sinks:
|
||||||
|
try:
|
||||||
|
sink.write(data)
|
||||||
|
sink.flush()
|
||||||
|
except (BrokenPipeError, OSError, ValueError):
|
||||||
|
_unregister_audio_sink(sink)
|
||||||
|
else:
|
||||||
|
# No audio from decoder — feed silence if client connected
|
||||||
|
sinks = _get_audio_sinks()
|
||||||
|
for sink in sinks:
|
||||||
|
try:
|
||||||
|
sink.write(_SILENCE_CHUNK)
|
||||||
|
sink.flush()
|
||||||
|
except (BrokenPipeError, OSError, ValueError):
|
||||||
|
_unregister_audio_sink(sink)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_put(event: dict):
|
||||||
|
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||||
|
try:
|
||||||
|
dmr_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
dmr_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
dmr_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||||
|
"""Read DSD stderr output and push parsed events to the queue.
|
||||||
|
|
||||||
|
Uses select() with a timeout so we can send periodic heartbeat
|
||||||
|
events while readline() would otherwise block indefinitely during
|
||||||
|
silence (no signal being decoded).
|
||||||
|
"""
|
||||||
|
global dmr_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
_queue_put({'type': 'status', 'text': 'started'})
|
||||||
|
last_heartbeat = time.time()
|
||||||
|
|
||||||
|
while dmr_running:
|
||||||
|
if dsd_process.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Wait up to 1s for data on stderr instead of blocking forever
|
||||||
|
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
line = dsd_process.stderr.readline()
|
||||||
|
if not line:
|
||||||
|
if dsd_process.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.debug("DSD raw: %s", text)
|
||||||
|
parsed = parse_dsd_output(text)
|
||||||
|
if parsed:
|
||||||
|
_queue_put(parsed)
|
||||||
|
last_heartbeat = time.time()
|
||||||
|
else:
|
||||||
|
# No stderr output — send heartbeat so frontend knows
|
||||||
|
# decoder is still alive and listening
|
||||||
|
now = time.time()
|
||||||
|
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
|
||||||
|
_queue_put({
|
||||||
|
'type': 'heartbeat',
|
||||||
|
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||||
|
})
|
||||||
|
last_heartbeat = now
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DSD stream error: {e}")
|
||||||
|
finally:
|
||||||
|
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
||||||
|
global dmr_has_audio
|
||||||
|
dmr_running = False
|
||||||
|
dmr_has_audio = False
|
||||||
|
with _ffmpeg_sinks_lock:
|
||||||
|
_ffmpeg_sinks.clear()
|
||||||
|
# Capture exit info for diagnostics
|
||||||
|
rc = dsd_process.poll()
|
||||||
|
reason = 'stopped'
|
||||||
|
detail = ''
|
||||||
|
if rc is not None and rc != 0:
|
||||||
|
reason = 'crashed'
|
||||||
|
try:
|
||||||
|
remaining = dsd_process.stderr.read(1024)
|
||||||
|
if remaining:
|
||||||
|
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||||
|
# Cleanup decoder + demod processes
|
||||||
|
_stop_process(dsd_process)
|
||||||
|
_stop_process(rtl_process)
|
||||||
|
dmr_rtl_process = None
|
||||||
|
dmr_dsd_process = None
|
||||||
|
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||||
|
# Release SDR device
|
||||||
|
if dmr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
|
dmr_active_device = None
|
||||||
|
logger.info("DSD stream thread stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# API ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@dmr_bp.route('/tools')
|
||||||
|
def check_tools() -> Response:
|
||||||
|
"""Check for required tools."""
|
||||||
|
dsd_path, _ = find_dsd()
|
||||||
|
rtl_fm = find_rtl_fm()
|
||||||
|
rx_fm = find_rx_fm()
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
|
return jsonify({
|
||||||
|
'dsd': dsd_path is not None,
|
||||||
|
'rtl_fm': rtl_fm is not None,
|
||||||
|
'rx_fm': rx_fm is not None,
|
||||||
|
'ffmpeg': ffmpeg is not None,
|
||||||
|
'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
|
||||||
|
'protocols': VALID_PROTOCOLS,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/start', methods=['POST'])
|
||||||
|
def start_dmr() -> Response:
|
||||||
|
"""Start digital voice decoding."""
|
||||||
|
global dmr_rtl_process, dmr_dsd_process, dmr_thread
|
||||||
|
global dmr_running, dmr_has_audio, dmr_active_device
|
||||||
|
|
||||||
|
dsd_path, is_fme = find_dsd()
|
||||||
|
if not dsd_path:
|
||||||
|
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
frequency = validate_frequency(data.get('frequency', 462.5625))
|
||||||
|
gain = int(validate_gain(data.get('gain', 40)))
|
||||||
|
device = validate_device_index(data.get('device', 0))
|
||||||
|
protocol = str(data.get('protocol', 'auto')).lower()
|
||||||
|
ppm = validate_ppm(data.get('ppm', 0))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||||
|
|
||||||
|
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
if protocol not in VALID_PROTOCOLS:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||||
|
|
||||||
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
|
if not find_rtl_fm():
|
||||||
|
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
|
||||||
|
else:
|
||||||
|
if not find_rx_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
# Clear stale queue
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
dmr_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reserve running state before we start claiming resources/processes
|
||||||
|
# so concurrent /start requests cannot race each other.
|
||||||
|
with dmr_lock:
|
||||||
|
if dmr_running:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||||
|
dmr_running = True
|
||||||
|
dmr_has_audio = False
|
||||||
|
|
||||||
|
# Claim SDR device — use protocol name so the device panel shows
|
||||||
|
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||||
|
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||||
|
error = app_module.claim_sdr_device(device, mode_label)
|
||||||
|
if error:
|
||||||
|
with dmr_lock:
|
||||||
|
dmr_running = False
|
||||||
|
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||||
|
|
||||||
|
dmr_active_device = device
|
||||||
|
|
||||||
|
# Build FM demodulation command via SDR abstraction.
|
||||||
|
try:
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
|
device=sdr_device,
|
||||||
|
frequency_mhz=frequency,
|
||||||
|
sample_rate=48000,
|
||||||
|
gain=float(gain) if gain > 0 else None,
|
||||||
|
ppm=int(ppm) if ppm != 0 else None,
|
||||||
|
modulation='fm',
|
||||||
|
squelch=None,
|
||||||
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
|
)
|
||||||
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
|
# Keep squelch fully open for digital bitstreams.
|
||||||
|
rtl_cmd.extend(['-l', '0'])
|
||||||
|
except Exception as e:
|
||||||
|
_reset_runtime_state(release_device=True)
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||||
|
|
||||||
|
# Build DSD command
|
||||||
|
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
|
||||||
|
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
|
||||||
|
# If ffmpeg is unavailable, fall back to discarding audio.
|
||||||
|
ffmpeg_path = find_ffmpeg()
|
||||||
|
if ffmpeg_path:
|
||||||
|
audio_out = '-'
|
||||||
|
else:
|
||||||
|
audio_out = 'null' if is_fme else '-'
|
||||||
|
logger.warning("ffmpeg not found — audio streaming disabled, data-only mode")
|
||||||
|
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
|
||||||
|
if is_fme:
|
||||||
|
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
|
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
|
||||||
|
# Event log to stderr so we capture TG/Source/Voice data that
|
||||||
|
# dsd-fme may not output on stderr by default.
|
||||||
|
dsd_cmd.extend(['-J', '/dev/stderr'])
|
||||||
|
# Relax CRC checks for marginal signals — lets more frames
|
||||||
|
# through at the cost of occasional decode errors.
|
||||||
|
if data.get('relaxCrc', False):
|
||||||
|
dsd_cmd.append('-F')
|
||||||
|
else:
|
||||||
|
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
|
|
||||||
|
try:
|
||||||
|
dmr_rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
register_process(dmr_rtl_process)
|
||||||
|
|
||||||
|
# DSD stdout → PIPE when ffmpeg available (audio pipeline),
|
||||||
|
# otherwise DEVNULL (data-only mode)
|
||||||
|
dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL
|
||||||
|
dmr_dsd_process = subprocess.Popen(
|
||||||
|
dsd_cmd,
|
||||||
|
stdin=dmr_rtl_process.stdout,
|
||||||
|
stdout=dsd_stdout,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
register_process(dmr_dsd_process)
|
||||||
|
|
||||||
|
# Allow rtl_fm to send directly to dsd
|
||||||
|
dmr_rtl_process.stdout.close()
|
||||||
|
|
||||||
|
# Start mux thread: always drains dsd-fme stdout to prevent the
|
||||||
|
# process from blocking (which would freeze stderr / text data).
|
||||||
|
# ffmpeg is started lazily per-client in /dmr/audio/stream.
|
||||||
|
if ffmpeg_path and dmr_dsd_process.stdout:
|
||||||
|
dmr_has_audio = True
|
||||||
|
threading.Thread(
|
||||||
|
target=_dsd_audio_mux,
|
||||||
|
args=(dmr_dsd_process.stdout,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
rtl_rc = dmr_rtl_process.poll()
|
||||||
|
dsd_rc = dmr_dsd_process.poll()
|
||||||
|
if rtl_rc is not None or dsd_rc is not None:
|
||||||
|
# Process died — capture stderr for diagnostics
|
||||||
|
rtl_err = ''
|
||||||
|
if dmr_rtl_process.stderr:
|
||||||
|
rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||||
|
dsd_err = ''
|
||||||
|
if dmr_dsd_process.stderr:
|
||||||
|
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||||
|
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
|
||||||
|
# Terminate surviving processes and release resources.
|
||||||
|
_reset_runtime_state(release_device=True)
|
||||||
|
# Surface a clear error to the user
|
||||||
|
detail = rtl_err.strip() or dsd_err.strip()
|
||||||
|
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||||
|
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
|
||||||
|
elif detail:
|
||||||
|
msg = f'Failed to start DSD pipeline: {detail}'
|
||||||
|
else:
|
||||||
|
msg = 'Failed to start DSD pipeline'
|
||||||
|
return jsonify({'status': 'error', 'message': msg}), 500
|
||||||
|
|
||||||
|
# Drain rtl_fm stderr in background to prevent pipe blocking
|
||||||
|
def _drain_rtl_stderr(proc):
|
||||||
|
try:
|
||||||
|
for line in proc.stderr:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
|
||||||
|
|
||||||
|
dmr_thread = threading.Thread(
|
||||||
|
target=stream_dsd_output,
|
||||||
|
args=(dmr_rtl_process, dmr_dsd_process),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
dmr_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'protocol': protocol,
|
||||||
|
'sdr_type': sdr_type.value,
|
||||||
|
'has_audio': dmr_has_audio,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start DMR: {e}")
|
||||||
|
_reset_runtime_state(release_device=True)
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_dmr() -> Response:
|
||||||
|
"""Stop digital voice decoding."""
|
||||||
|
with dmr_lock:
|
||||||
|
_reset_runtime_state(release_device=True)
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/status')
|
||||||
|
def dmr_status() -> Response:
|
||||||
|
"""Get DMR decoder status."""
|
||||||
|
return jsonify({
|
||||||
|
'running': dmr_running,
|
||||||
|
'device': dmr_active_device,
|
||||||
|
'has_audio': dmr_has_audio,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/audio/stream')
|
||||||
|
def stream_dmr_audio() -> Response:
|
||||||
|
"""Stream decoded digital voice audio as WAV.
|
||||||
|
|
||||||
|
Starts a per-client ffmpeg encoder. The global mux thread
|
||||||
|
(_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while
|
||||||
|
the client is connected, and discards audio otherwise. This avoids
|
||||||
|
the pipe-buffer deadlock that occurs when ffmpeg is started at
|
||||||
|
decoder launch (its stdout fills up before any HTTP client reads
|
||||||
|
it, back-pressuring the entire pipeline and freezing stderr/text
|
||||||
|
data output).
|
||||||
|
"""
|
||||||
|
if not dmr_running or not dmr_has_audio:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
|
ffmpeg_path = find_ffmpeg()
|
||||||
|
if not ffmpeg_path:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=503)
|
||||||
|
|
||||||
|
encoder_cmd = [
|
||||||
|
ffmpeg_path, '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-fflags', 'nobuffer', '-flags', 'low_delay',
|
||||||
|
'-probesize', '32', '-analyzeduration', '0',
|
||||||
|
'-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0',
|
||||||
|
'-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1',
|
||||||
|
]
|
||||||
|
audio_proc = subprocess.Popen(
|
||||||
|
encoder_cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
# Drain ffmpeg stderr to prevent blocking
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda p: [None for _ in p.stderr],
|
||||||
|
args=(audio_proc,), daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
if audio_proc.stdin:
|
||||||
|
_register_audio_sink(audio_proc.stdin)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
try:
|
||||||
|
while dmr_running and audio_proc.poll() is None:
|
||||||
|
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
|
||||||
|
if ready:
|
||||||
|
chunk = audio_proc.stdout.read(4096)
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if audio_proc.poll() is not None:
|
||||||
|
break
|
||||||
|
except GeneratorExit:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DMR audio stream error: {e}")
|
||||||
|
finally:
|
||||||
|
# Disconnect mux → ffmpeg, then clean up
|
||||||
|
if audio_proc.stdin:
|
||||||
|
_unregister_audio_sink(audio_proc.stdin)
|
||||||
|
try:
|
||||||
|
audio_proc.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
audio_proc.terminate()
|
||||||
|
audio_proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
audio_proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate(),
|
||||||
|
mimetype='audio/wav',
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dmr_bp.route('/stream')
|
||||||
|
def stream_dmr() -> Response:
|
||||||
|
"""SSE stream for DMR decoder events."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('dmr', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
+74
-18
@@ -36,9 +36,11 @@ from utils.database import (
|
|||||||
)
|
)
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dsc')
|
logger = logging.getLogger('intercept.dsc')
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
|
|||||||
# Module state (track if running independent of process state)
|
# Module state (track if running independent of process state)
|
||||||
dsc_running = False
|
dsc_running = False
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
dsc_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_dsc_decoder_path() -> str | None:
|
def _get_dsc_decoder_path() -> str | None:
|
||||||
"""Get path to DSC decoder."""
|
"""Get path to DSC decoder."""
|
||||||
@@ -166,17 +171,34 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
|
global dsc_active_device
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
decoder_process.wait()
|
|
||||||
dsc_running = False
|
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'})
|
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
app_module.dsc_process = None
|
app_module.dsc_process = None
|
||||||
app_module.dsc_rtl_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:
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
@@ -309,21 +331,18 @@ def start_decoding() -> Response:
|
|||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Check if device is in use by AIS
|
# Check if device is available using centralized registry
|
||||||
try:
|
global dsc_active_device
|
||||||
from routes import ais as ais_module
|
device_int = int(device)
|
||||||
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
|
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||||
# AIS is running - check if same device
|
if error:
|
||||||
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
|
return jsonify({
|
||||||
return jsonify({
|
'status': 'error',
|
||||||
'status': 'error',
|
'error_type': 'DEVICE_BUSY',
|
||||||
'error_type': 'DEVICE_BUSY',
|
'message': error
|
||||||
'message': f'SDR device {device} is in use by AIS tracking',
|
}), 409
|
||||||
'suggestion': 'Use a different SDR device or stop AIS tracking first',
|
|
||||||
'in_use_by': 'ais'
|
dsc_active_device = device_int
|
||||||
}), 409
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.dsc_queue.empty():
|
while not app_module.dsc_queue.empty():
|
||||||
@@ -362,6 +381,7 @@ def start_decoding() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(rtl_process)
|
||||||
|
|
||||||
# Start stderr monitor thread
|
# Start stderr monitor thread
|
||||||
stderr_thread = threading.Thread(
|
stderr_thread = threading.Thread(
|
||||||
@@ -382,6 +402,7 @@ def start_decoding() -> Response:
|
|||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
)
|
)
|
||||||
|
register_process(decoder_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
rtl_process.stdout.close()
|
rtl_process.stdout.close()
|
||||||
@@ -408,11 +429,37 @@ def start_decoding() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
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)
|
||||||
|
dsc_active_device = None
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Tool not found: {e.filename}'
|
'message': f'Tool not found: {e.filename}'
|
||||||
}), 400
|
}), 400
|
||||||
except Exception as e:
|
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)
|
||||||
|
dsc_active_device = None
|
||||||
logger.error(f"Failed to start DSC decoder: {e}")
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -423,7 +470,7 @@ def start_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stop', methods=['POST'])
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
"""Stop DSC decoder."""
|
"""Stop DSC decoder."""
|
||||||
global dsc_running
|
global dsc_running, dsc_active_device
|
||||||
|
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
if not app_module.dsc_process:
|
if not app_module.dsc_process:
|
||||||
@@ -460,6 +507,11 @@ def stop_decoding() -> Response:
|
|||||||
app_module.dsc_process = None
|
app_module.dsc_process = None
|
||||||
app_module.dsc_rtl_process = None
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device)
|
||||||
|
dsc_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -474,6 +526,10 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.dsc_queue.get(timeout=1)
|
msg = app_module.dsc_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('dsc', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+99
-32
@@ -4,19 +4,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import queue
|
import queue
|
||||||
import time
|
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.gps import (
|
||||||
|
GPSPosition,
|
||||||
|
GPSSkyData,
|
||||||
|
detect_gps_devices,
|
||||||
|
get_current_position,
|
||||||
|
get_gps_reader,
|
||||||
|
is_gpsd_running,
|
||||||
|
start_gpsd,
|
||||||
|
start_gpsd_daemon,
|
||||||
|
stop_gps,
|
||||||
|
stop_gpsd_daemon,
|
||||||
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.gps import (
|
|
||||||
get_gps_reader,
|
|
||||||
start_gpsd,
|
|
||||||
stop_gps,
|
|
||||||
get_current_position,
|
|
||||||
GPSPosition,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -29,12 +34,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
def _position_callback(position: GPSPosition) -> None:
|
def _position_callback(position: GPSPosition) -> None:
|
||||||
"""Callback to queue position updates for SSE stream."""
|
"""Callback to queue position updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_gps_queue.put_nowait(position.to_dict())
|
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Discard oldest if queue is full
|
# Discard oldest if queue is full
|
||||||
try:
|
try:
|
||||||
_gps_queue.get_nowait()
|
_gps_queue.get_nowait()
|
||||||
_gps_queue.put_nowait(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:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -45,36 +62,44 @@ def auto_connect_gps():
|
|||||||
Automatically connect to gpsd if available.
|
Automatically connect to gpsd if available.
|
||||||
|
|
||||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||||
|
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||||
Returns current status if already connected.
|
Returns current status if already connected.
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': position is not None,
|
'has_fix': position is not None,
|
||||||
'position': position.to_dict() if position else None
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Try to connect to gpsd on localhost:2947
|
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
port = 2947
|
port = 2947
|
||||||
|
|
||||||
# First check if gpsd is reachable
|
# If gpsd isn't running, try to detect a device and start it
|
||||||
try:
|
if not is_gpsd_running(host, port):
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
devices = detect_gps_devices()
|
||||||
sock.settimeout(1.0)
|
if not devices:
|
||||||
sock.connect((host, port))
|
return jsonify({
|
||||||
sock.close()
|
'status': 'unavailable',
|
||||||
except Exception:
|
'message': 'No GPS device detected'
|
||||||
return jsonify({
|
})
|
||||||
'status': 'unavailable',
|
|
||||||
'message': 'gpsd not running'
|
# Try to start gpsd with the first detected device
|
||||||
})
|
device_path = devices[0]['path']
|
||||||
|
success, msg = start_gpsd_daemon(device_path, host, port)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': msg,
|
||||||
|
'devices': devices,
|
||||||
|
})
|
||||||
|
logger.info(f"Auto-started gpsd on {device_path}")
|
||||||
|
|
||||||
# Clear the queue
|
# Clear the queue
|
||||||
while not _gps_queue.empty():
|
while not _gps_queue.empty():
|
||||||
@@ -84,14 +109,17 @@ def auto_connect_gps():
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Start the gpsd client
|
# 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:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': False,
|
'has_fix': False,
|
||||||
'position': None
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -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'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS client."""
|
"""Stop GPS client and gpsd daemon if we started it."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader:
|
if reader:
|
||||||
reader.remove_callback(_position_callback)
|
reader.remove_callback(_position_callback)
|
||||||
|
reader.remove_sky_callback(_sky_callback)
|
||||||
|
|
||||||
stop_gps()
|
stop_gps()
|
||||||
|
stop_gpsd_daemon()
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -122,15 +162,18 @@ def get_gps_status():
|
|||||||
'running': False,
|
'running': False,
|
||||||
'device': None,
|
'device': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
'error': None,
|
'error': None,
|
||||||
'message': 'GPS client not started'
|
'message': 'GPS client not started'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': reader.is_running,
|
'running': reader.is_running,
|
||||||
'device': reader.device_path,
|
'device': reader.device_path,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||||
'error': reader.error,
|
'error': reader.error,
|
||||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||||
@@ -161,18 +204,42 @@ def get_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/satellites')
|
||||||
|
def get_satellites():
|
||||||
|
"""Get current satellite sky view data."""
|
||||||
|
reader = get_gps_reader()
|
||||||
|
|
||||||
|
if not reader or not reader.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'GPS client not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
sky = reader.sky
|
||||||
|
if sky:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'sky': sky.to_dict()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'waiting',
|
||||||
|
'message': 'Waiting for satellite data'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position updates."""
|
"""SSE stream of GPS position and sky updates."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
position = _gps_queue.get(timeout=1)
|
data = _gps_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
yield format_sse({'type': 'position', **position})
|
yield format_sse(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
+1057
-101
File diff suppressed because it is too large
Load Diff
+49
-9
@@ -3,8 +3,9 @@
|
|||||||
Provides endpoints for connecting to Meshtastic devices, configuring
|
Provides endpoints for connecting to Meshtastic devices, configuring
|
||||||
channels with encryption keys, and streaming received messages.
|
channels with encryption keys, and streaming received messages.
|
||||||
|
|
||||||
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
|
Supports multiple connection types:
|
||||||
connected via USB/Serial.
|
- USB/Serial: Physical device connected via USB
|
||||||
|
- TCP: WiFi-enabled devices accessible via IP address
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -95,7 +96,7 @@ def get_status():
|
|||||||
Get Meshtastic connection status.
|
Get Meshtastic connection status.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with connection status, device info, and node information.
|
JSON with connection status, device info, connection type, and node information.
|
||||||
"""
|
"""
|
||||||
if not is_meshtastic_available():
|
if not is_meshtastic_available():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -111,6 +112,7 @@ def get_status():
|
|||||||
'available': True,
|
'available': True,
|
||||||
'running': False,
|
'running': False,
|
||||||
'device': None,
|
'device': None,
|
||||||
|
'connection_type': None,
|
||||||
'node_info': None,
|
'node_info': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -120,6 +122,7 @@ def get_status():
|
|||||||
'available': True,
|
'available': True,
|
||||||
'running': client.is_running,
|
'running': client.is_running,
|
||||||
'device': client.device_path,
|
'device': client.device_path,
|
||||||
|
'connection_type': client.connection_type,
|
||||||
'error': client.error,
|
'error': client.error,
|
||||||
'node_info': node_info.to_dict() if node_info else None,
|
'node_info': node_info.to_dict() if node_info else None,
|
||||||
})
|
})
|
||||||
@@ -131,13 +134,20 @@ def start_mesh():
|
|||||||
Start Meshtastic listener.
|
Start Meshtastic listener.
|
||||||
|
|
||||||
Connects to a Meshtastic device and begins receiving messages.
|
Connects to a Meshtastic device and begins receiving messages.
|
||||||
The device must be connected via USB/Serial.
|
Supports both USB/Serial and TCP connections.
|
||||||
|
|
||||||
JSON body (optional):
|
JSON body (optional):
|
||||||
{
|
{
|
||||||
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
|
"connection_type": "serial", // 'serial' (default) or 'tcp'
|
||||||
|
"device": "/dev/ttyUSB0", // Serial port path. Auto-discovers if not provided.
|
||||||
|
"hostname": "192.168.1.100" // IP address or hostname for TCP connections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Serial (auto-discover): {}
|
||||||
|
Serial (specific port): {"device": "/dev/ttyUSB0"}
|
||||||
|
TCP: {"connection_type": "tcp", "hostname": "192.168.1.100"}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with connection status.
|
JSON with connection status.
|
||||||
"""
|
"""
|
||||||
@@ -151,7 +161,8 @@ def start_mesh():
|
|||||||
if client and client.is_running:
|
if client and client.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'device': client.device_path
|
'device': client.device_path,
|
||||||
|
'connection_type': client.connection_type
|
||||||
})
|
})
|
||||||
|
|
||||||
# Clear queue and history
|
# Clear queue and history
|
||||||
@@ -162,18 +173,46 @@ def start_mesh():
|
|||||||
break
|
break
|
||||||
_recent_messages.clear()
|
_recent_messages.clear()
|
||||||
|
|
||||||
# Get optional device path
|
# Parse connection parameters
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
connection_type = data.get('connection_type', 'serial').lower().strip()
|
||||||
device = data.get('device')
|
device = data.get('device')
|
||||||
|
hostname = data.get('hostname')
|
||||||
|
|
||||||
# Validate device path if provided
|
# Validate connection type
|
||||||
|
if connection_type not in ('serial', 'tcp'):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate TCP parameters
|
||||||
|
if connection_type == 'tcp':
|
||||||
|
if not hostname:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'hostname is required for TCP connections'
|
||||||
|
}), 400
|
||||||
|
hostname = str(hostname).strip()
|
||||||
|
if not hostname:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'hostname cannot be empty'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate serial device path if provided
|
||||||
if device:
|
if device:
|
||||||
device = str(device).strip()
|
device = str(device).strip()
|
||||||
if not device:
|
if not device:
|
||||||
device = None
|
device = None
|
||||||
|
|
||||||
# Start client
|
# Start client
|
||||||
success = start_meshtastic(device=device, callback=_message_callback)
|
success = start_meshtastic(
|
||||||
|
device=device,
|
||||||
|
callback=_message_callback,
|
||||||
|
connection_type=connection_type,
|
||||||
|
hostname=hostname
|
||||||
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
client = get_meshtastic_client()
|
client = get_meshtastic_client()
|
||||||
@@ -181,6 +220,7 @@ def start_mesh():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'device': client.device_path if client else None,
|
'device': client.device_path if client else None,
|
||||||
|
'connection_type': client.connection_type if client else None,
|
||||||
'node_info': node_info.to_dict() if node_info else None,
|
'node_info': node_info.to_dict() if node_info else None,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|||||||
+4
-1
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
|
|||||||
'offline.enabled': False,
|
'offline.enabled': False,
|
||||||
'offline.assets_source': 'cdn',
|
'offline.assets_source': 'cdn',
|
||||||
'offline.fonts_source': 'cdn',
|
'offline.fonts_source': 'cdn',
|
||||||
'offline.tile_provider': 'openstreetmap',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +44,9 @@ ASSET_PATHS = {
|
|||||||
'static/vendor/leaflet/images/marker-shadow.png',
|
'static/vendor/leaflet/images/marker-shadow.png',
|
||||||
'static/vendor/leaflet/images/layers.png',
|
'static/vendor/leaflet/images/layers.png',
|
||||||
'static/vendor/leaflet/images/layers-2x.png'
|
'static/vendor/leaflet/images/layers-2x.png'
|
||||||
|
],
|
||||||
|
'leaflet_heat': [
|
||||||
|
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+165
-8
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import pty
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import select
|
import select
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -23,12 +25,16 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.process import safe_terminate, register_process
|
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.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
pager_bp = Blueprint('pager', __name__)
|
pager_bp = Blueprint('pager', __name__)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
pager_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||||
"""Parse multimon-ng output line."""
|
"""Parse multimon-ng output line."""
|
||||||
@@ -102,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def 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 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,
|
||||||
|
})
|
||||||
|
except (struct.error, ValueError, queue.Full):
|
||||||
|
pass
|
||||||
|
except Exception as 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:
|
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||||
try:
|
try:
|
||||||
@@ -143,18 +205,43 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
|
global pager_active_device
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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'})
|
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
|
# Release SDR device
|
||||||
|
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'])
|
@pager_bp.route('/start', methods=['POST'])
|
||||||
def start_decoding() -> Response:
|
def start_decoding() -> Response:
|
||||||
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||||
@@ -178,10 +265,29 @@ def start_decoding() -> Response:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||||
|
|
||||||
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Claim local device if not using remote rtl_tcp
|
||||||
|
if not rtl_tcp_host:
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
pager_active_device = device_int
|
||||||
|
|
||||||
# Validate protocols
|
# Validate protocols
|
||||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||||
protocols = data.get('protocols', valid_protocols)
|
protocols = data.get('protocols', valid_protocols)
|
||||||
if not isinstance(protocols, list):
|
if not isinstance(protocols, list):
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
@@ -213,10 +319,6 @@ def start_decoding() -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -261,6 +363,7 @@ def start_decoding() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(rtl_process)
|
||||||
|
|
||||||
# Start a thread to monitor rtl_fm stderr for errors
|
# Start a thread to monitor rtl_fm stderr for errors
|
||||||
def monitor_rtl_stderr():
|
def monitor_rtl_stderr():
|
||||||
@@ -279,18 +382,30 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_process = subprocess.Popen(
|
multimon_process = subprocess.Popen(
|
||||||
multimon_cmd,
|
multimon_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=subprocess.PIPE,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
)
|
)
|
||||||
|
register_process(multimon_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
rtl_process.stdout.close()
|
|
||||||
|
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||||||
|
stop_relay = threading.Event()
|
||||||
|
relay = threading.Thread(
|
||||||
|
target=audio_relay_thread,
|
||||||
|
args=(rtl_process.stdout, multimon_process.stdin,
|
||||||
|
app_module.output_queue, stop_relay),
|
||||||
|
)
|
||||||
|
relay.daemon = True
|
||||||
|
relay.start()
|
||||||
|
|
||||||
app_module.current_process = multimon_process
|
app_module.current_process = multimon_process
|
||||||
app_module.current_process._rtl_process = rtl_process
|
app_module.current_process._rtl_process = rtl_process
|
||||||
app_module.current_process._master_fd = master_fd
|
app_module.current_process._master_fd = master_fd
|
||||||
|
app_module.current_process._stop_relay = stop_relay
|
||||||
|
app_module.current_process._relay_thread = relay
|
||||||
|
|
||||||
# Start output thread with PTY master fd
|
# Start output thread with PTY master fd
|
||||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||||
@@ -302,15 +417,47 @@ def start_decoding() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
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}'})
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Kill orphaned rtl_fm process if it was started
|
||||||
|
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': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
|
global pager_active_device
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
|
# Signal audio relay thread to stop
|
||||||
|
if hasattr(app_module.current_process, '_stop_relay'):
|
||||||
|
app_module.current_process._stop_relay.set()
|
||||||
|
|
||||||
# Kill rtl_fm process first
|
# Kill rtl_fm process first
|
||||||
if hasattr(app_module.current_process, '_rtl_process'):
|
if hasattr(app_module.current_process, '_rtl_process'):
|
||||||
try:
|
try:
|
||||||
@@ -337,6 +484,12 @@ def stop_decoding() -> Response:
|
|||||||
app_module.current_process.kill()
|
app_module.current_process.kill()
|
||||||
|
|
||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if pager_active_device is not None:
|
||||||
|
app_module.release_sdr_device(pager_active_device)
|
||||||
|
pager_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
return jsonify({'status': 'not_running'})
|
return jsonify({'status': 'not_running'})
|
||||||
@@ -397,6 +550,10 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
msg = app_module.output_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('pager', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Session recording API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
+70
-6
@@ -18,7 +18,8 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.process import safe_terminate, register_process
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
|
||||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ rtlamr_bp = Blueprint('rtlamr', __name__)
|
|||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
rtl_tcp_lock = threading.Lock()
|
rtl_tcp_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
rtlamr_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtlamr JSON output to queue."""
|
"""Stream rtlamr JSON output to queue."""
|
||||||
@@ -58,15 +62,42 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
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'})
|
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
app_module.rtlamr_process = None
|
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'])
|
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||||
def start_rtlamr() -> Response:
|
def start_rtlamr() -> Response:
|
||||||
global rtl_tcp_process
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
@@ -83,6 +114,18 @@ def start_rtlamr() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
rtlamr_active_device = device_int
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.rtlamr_queue.empty():
|
while not app_module.rtlamr_queue.empty():
|
||||||
try:
|
try:
|
||||||
@@ -118,6 +161,7 @@ def start_rtlamr() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(rtl_tcp_process)
|
||||||
|
|
||||||
# Wait a moment for rtl_tcp to start
|
# Wait a moment for rtl_tcp to start
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
@@ -126,6 +170,10 @@ def start_rtlamr() -> Response:
|
|||||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||||
|
# Release SDR device on rtl_tcp failure
|
||||||
|
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
|
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||||
|
|
||||||
# Build rtlamr command
|
# Build rtlamr command
|
||||||
@@ -159,6 +207,7 @@ def start_rtlamr() -> Response:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(app_module.rtlamr_process)
|
||||||
|
|
||||||
# Start output thread
|
# Start output thread
|
||||||
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
||||||
@@ -182,26 +231,32 @@ def start_rtlamr() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# If rtlamr fails, clean up rtl_tcp
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if rtl_tcp_process:
|
if rtl_tcp_process:
|
||||||
rtl_tcp_process.terminate()
|
rtl_tcp_process.terminate()
|
||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If rtlamr fails, clean up rtl_tcp
|
# If rtlamr fails, clean up rtl_tcp and release device
|
||||||
with rtl_tcp_lock:
|
with rtl_tcp_lock:
|
||||||
if rtl_tcp_process:
|
if rtl_tcp_process:
|
||||||
rtl_tcp_process.terminate()
|
rtl_tcp_process.terminate()
|
||||||
rtl_tcp_process.wait(timeout=2)
|
rtl_tcp_process.wait(timeout=2)
|
||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||||
def stop_rtlamr() -> Response:
|
def stop_rtlamr() -> Response:
|
||||||
global rtl_tcp_process
|
global rtl_tcp_process, rtlamr_active_device
|
||||||
|
|
||||||
with app_module.rtlamr_lock:
|
with app_module.rtlamr_lock:
|
||||||
if app_module.rtlamr_process:
|
if app_module.rtlamr_process:
|
||||||
@@ -223,6 +278,11 @@ def stop_rtlamr() -> Response:
|
|||||||
rtl_tcp_process = None
|
rtl_tcp_process = None
|
||||||
logger.info("rtl_tcp stopped")
|
logger.info("rtl_tcp stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if rtlamr_active_device is not None:
|
||||||
|
app_module.release_sdr_device(rtlamr_active_device)
|
||||||
|
rtlamr_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -236,6 +296,10 @@ def stream_rtlamr() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('rtlamr', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+141
-30
@@ -13,7 +13,16 @@ import requests
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, render_template, Response
|
from flask import Blueprint, jsonify, request, render_template, Response
|
||||||
|
|
||||||
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
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.logging import satellite_logger as logger
|
||||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||||
|
|
||||||
@@ -29,6 +38,43 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_db_satellites_into_cache():
|
||||||
|
"""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]:
|
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Fetch real-time ISS position from external APIs.
|
Fetch real-time ISS position from external APIs.
|
||||||
@@ -40,30 +86,30 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
|||||||
iss_alt = 420 # Default altitude in km
|
iss_alt = 420 # Default altitude in km
|
||||||
source = None
|
source = None
|
||||||
|
|
||||||
# Try primary API: Open Notify
|
# Try primary API: Where The ISS At
|
||||||
try:
|
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:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if data.get('message') == 'success':
|
iss_lat = float(data['latitude'])
|
||||||
iss_lat = float(data['iss_position']['latitude'])
|
iss_lon = float(data['longitude'])
|
||||||
iss_lon = float(data['iss_position']['longitude'])
|
iss_alt = float(data.get('altitude', 420))
|
||||||
source = 'open-notify'
|
source = 'wheretheiss'
|
||||||
except Exception as e:
|
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:
|
if iss_lat is None:
|
||||||
try:
|
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:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
iss_lat = float(data['latitude'])
|
if data.get('message') == 'success':
|
||||||
iss_lon = float(data['longitude'])
|
iss_lat = float(data['iss_position']['latitude'])
|
||||||
iss_alt = float(data.get('altitude', 420))
|
iss_lon = float(data['iss_position']['longitude'])
|
||||||
source = 'wheretheiss'
|
source = 'open-notify'
|
||||||
except Exception as e:
|
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:
|
if iss_lat is None:
|
||||||
return None
|
return None
|
||||||
@@ -120,7 +166,10 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
|||||||
@satellite_bp.route('/dashboard')
|
@satellite_bp.route('/dashboard')
|
||||||
def satellite_dashboard():
|
def satellite_dashboard():
|
||||||
"""Popout satellite tracking dashboard."""
|
"""Popout satellite tracking dashboard."""
|
||||||
return render_template('satellite_dashboard.html')
|
return render_template(
|
||||||
|
'satellite_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/predict', methods=['POST'])
|
@satellite_bp.route('/predict', methods=['POST'])
|
||||||
@@ -148,15 +197,11 @@ def predict_passes():
|
|||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
25338: 'NOAA-15',
|
|
||||||
28654: 'NOAA-18',
|
|
||||||
33591: 'NOAA-19',
|
|
||||||
43013: 'NOAA-20',
|
|
||||||
40069: 'METEOR-M2',
|
40069: 'METEOR-M2',
|
||||||
57166: 'METEOR-M2-3'
|
57166: 'METEOR-M2-3'
|
||||||
}
|
}
|
||||||
|
|
||||||
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
||||||
satellites = []
|
satellites = []
|
||||||
for sat in sat_input:
|
for sat in sat_input:
|
||||||
if isinstance(sat, int) and sat in norad_to_name:
|
if isinstance(sat, int) and sat in norad_to_name:
|
||||||
@@ -167,10 +212,6 @@ def predict_passes():
|
|||||||
passes = []
|
passes = []
|
||||||
colors = {
|
colors = {
|
||||||
'ISS': '#00ffff',
|
'ISS': '#00ffff',
|
||||||
'NOAA-15': '#00ff00',
|
|
||||||
'NOAA-18': '#ff6600',
|
|
||||||
'NOAA-19': '#ff3366',
|
|
||||||
'NOAA-20': '#00ffaa',
|
|
||||||
'METEOR-M2': '#9370DB',
|
'METEOR-M2': '#9370DB',
|
||||||
'METEOR-M2-3': '#ff00ff'
|
'METEOR-M2-3': '#ff00ff'
|
||||||
}
|
}
|
||||||
@@ -307,10 +348,6 @@ def get_satellite_position():
|
|||||||
|
|
||||||
norad_to_name = {
|
norad_to_name = {
|
||||||
25544: 'ISS',
|
25544: 'ISS',
|
||||||
25338: 'NOAA-15',
|
|
||||||
28654: 'NOAA-18',
|
|
||||||
33591: 'NOAA-19',
|
|
||||||
43013: 'NOAA-20',
|
|
||||||
40069: 'METEOR-M2',
|
40069: 'METEOR-M2',
|
||||||
57166: 'METEOR-M2-3'
|
57166: 'METEOR-M2-3'
|
||||||
}
|
}
|
||||||
@@ -476,7 +513,8 @@ def update_tle():
|
|||||||
'updated': updated
|
'updated': updated
|
||||||
})
|
})
|
||||||
except Exception as e:
|
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>')
|
@satellite_bp.route('/celestrak/<category>')
|
||||||
@@ -530,4 +568,77 @@ def fetch_celestrak(category):
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tracked Satellites CRUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked', methods=['GET'])
|
||||||
|
def list_tracked_satellites():
|
||||||
|
"""Return all tracked satellites from the database."""
|
||||||
|
enabled_only = request.args.get('enabled', '').lower() == 'true'
|
||||||
|
sats = get_tracked_satellites(enabled_only=enabled_only)
|
||||||
|
return jsonify({'status': 'success', 'satellites': sats})
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked', methods=['POST'])
|
||||||
|
def add_tracked_satellites_endpoint():
|
||||||
|
"""Add one or more tracked satellites."""
|
||||||
|
global _tle_cache
|
||||||
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
|
# Accept a single satellite dict or a list
|
||||||
|
sat_list = data if isinstance(data, list) else [data]
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
for sat in sat_list:
|
||||||
|
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||||
|
name = sat.get('name', '')
|
||||||
|
if not norad_id or not name:
|
||||||
|
continue
|
||||||
|
tle1 = sat.get('tle_line1', sat.get('tle1'))
|
||||||
|
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||||
|
enabled = sat.get('enabled', True)
|
||||||
|
|
||||||
|
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
# Also inject into TLE cache if we have TLE data
|
||||||
|
if tle1 and tle2:
|
||||||
|
cache_key = name.replace(' ', '-').upper()
|
||||||
|
_tle_cache[cache_key] = (name, tle1, tle2)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'added': added,
|
||||||
|
'satellites': get_tracked_satellites(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
|
||||||
|
def update_tracked_satellite_endpoint(norad_id):
|
||||||
|
"""Update the enabled state of a tracked satellite."""
|
||||||
|
data = request.json or {}
|
||||||
|
enabled = data.get('enabled')
|
||||||
|
if enabled is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
|
||||||
|
|
||||||
|
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
||||||
|
if ok:
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
|
||||||
|
def delete_tracked_satellite_endpoint(norad_id):
|
||||||
|
"""Remove a tracked satellite by NORAD ID."""
|
||||||
|
ok, msg = remove_tracked_satellite(str(norad_id))
|
||||||
|
if ok:
|
||||||
|
return jsonify({'status': 'success', 'message': msg})
|
||||||
|
status_code = 403 if 'builtin' in msg.lower() else 404
|
||||||
|
return jsonify({'status': 'error', 'message': msg}), status_code
|
||||||
|
|||||||
+93
-7
@@ -19,11 +19,15 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.process import safe_terminate, register_process
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
sensor_bp = Blueprint('sensor', __name__)
|
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:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
@@ -41,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
|
# Push scope event when signal level data is present
|
||||||
|
rssi = data.get('rssi')
|
||||||
|
snr = data.get('snr')
|
||||||
|
noise = data.get('noise')
|
||||||
|
if rssi is not None or snr is not None:
|
||||||
|
try:
|
||||||
|
app_module.sensor_queue.put_nowait({
|
||||||
|
'type': 'scope',
|
||||||
|
'rssi': rssi if rssi is not None else 0,
|
||||||
|
'snr': snr if snr is not None else 0,
|
||||||
|
'noise': noise if noise is not None else 0,
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -56,14 +75,38 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
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'})
|
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
|
# Release SDR device
|
||||||
|
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'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||||
@@ -79,6 +122,22 @@ def start_sensor() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# Claim local device if not using remote rtl_tcp
|
||||||
|
if not rtl_tcp_host:
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
sensor_active_device = device_int
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.sensor_queue.empty():
|
while not app_module.sensor_queue.empty():
|
||||||
try:
|
try:
|
||||||
@@ -93,10 +152,6 @@ def start_sensor() -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -126,12 +181,17 @@ def start_sensor() -> Response:
|
|||||||
full_cmd = ' '.join(cmd)
|
full_cmd = ' '.join(cmd)
|
||||||
logger.info(f"Running: {full_cmd}")
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
|
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||||
|
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||||
|
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.sensor_process = subprocess.Popen(
|
app_module.sensor_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
register_process(app_module.sensor_process)
|
||||||
|
|
||||||
# Start output thread
|
# Start output thread
|
||||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||||
@@ -139,10 +199,16 @@ def start_sensor() -> Response:
|
|||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
# Monitor stderr
|
# Monitor stderr
|
||||||
|
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||||
|
_stderr_noise = (
|
||||||
|
'bitbuffer_add_bit',
|
||||||
|
'row count limit',
|
||||||
|
)
|
||||||
|
|
||||||
def monitor_stderr():
|
def monitor_stderr():
|
||||||
for line in app_module.sensor_process.stderr:
|
for line in app_module.sensor_process.stderr:
|
||||||
err = line.decode('utf-8', errors='replace').strip()
|
err = line.decode('utf-8', errors='replace').strip()
|
||||||
if err:
|
if err and not any(noise in err for noise in _stderr_noise):
|
||||||
logger.debug(f"[rtl_433] {err}")
|
logger.debug(f"[rtl_433] {err}")
|
||||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||||
|
|
||||||
@@ -155,13 +221,23 @@ def start_sensor() -> Response:
|
|||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
# Release device on failure
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
def stop_sensor() -> Response:
|
def stop_sensor() -> Response:
|
||||||
|
global sensor_active_device
|
||||||
|
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
if app_module.sensor_process:
|
if app_module.sensor_process:
|
||||||
app_module.sensor_process.terminate()
|
app_module.sensor_process.terminate()
|
||||||
@@ -170,6 +246,12 @@ def stop_sensor() -> Response:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
app_module.sensor_process.kill()
|
app_module.sensor_process.kill()
|
||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if sensor_active_device is not None:
|
||||||
|
app_module.release_sdr_device(sensor_active_device)
|
||||||
|
sensor_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
return jsonify({'status': 'not_running'})
|
return jsonify({'status': 'not_running'})
|
||||||
@@ -185,6 +267,10 @@ def stream_sensor() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
msg = app_module.sensor_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sensor', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+224
-41
@@ -13,13 +13,14 @@ from typing import Generator
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
is_sstv_available,
|
is_sstv_available,
|
||||||
ISS_SSTV_FREQ,
|
ISS_SSTV_FREQ,
|
||||||
DecodeProgress,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.sstv')
|
logger = get_logger('intercept.sstv')
|
||||||
@@ -29,15 +30,18 @@ sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
|||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
# 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:
|
try:
|
||||||
_sstv_queue.put_nowait(progress.to_dict())
|
_sstv_queue.put_nowait(data)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_queue.get_nowait()
|
_sstv_queue.get_nowait()
|
||||||
_sstv_queue.put_nowait(progress.to_dict())
|
_sstv_queue.put_nowait(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -53,13 +57,21 @@ def get_status():
|
|||||||
available = is_sstv_available()
|
available = is_sstv_available()
|
||||||
decoder = get_sstv_decoder()
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
return jsonify({
|
result = {
|
||||||
'available': available,
|
'available': available,
|
||||||
'decoder': decoder.decoder_available,
|
'decoder': decoder.decoder_available,
|
||||||
'running': decoder.is_running,
|
'running': decoder.is_running,
|
||||||
'iss_frequency': ISS_SSTV_FREQ,
|
'iss_frequency': ISS_SSTV_FREQ,
|
||||||
'image_count': len(decoder.get_images()),
|
'image_count': len(decoder.get_images()),
|
||||||
})
|
'doppler_enabled': decoder.doppler_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include Doppler info if available
|
||||||
|
doppler_info = decoder.last_doppler_info
|
||||||
|
if doppler_info:
|
||||||
|
result['doppler'] = doppler_info.to_dict()
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/start', methods=['POST'])
|
@sstv_bp.route('/start', methods=['POST'])
|
||||||
@@ -70,16 +82,22 @@ def start_decoder():
|
|||||||
JSON body (optional):
|
JSON body (optional):
|
||||||
{
|
{
|
||||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||||
"device": 0 // RTL-SDR device index
|
"device": 0, // RTL-SDR device index
|
||||||
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||||
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
If latitude and longitude are provided, real-time Doppler shift compensation
|
||||||
|
will be enabled, which improves reception by tracking the ISS frequency shift
|
||||||
|
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with start status.
|
JSON with start status.
|
||||||
"""
|
"""
|
||||||
if not is_sstv_available():
|
if not is_sstv_available():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'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
|
}), 400
|
||||||
|
|
||||||
decoder = get_sstv_decoder()
|
decoder = get_sstv_decoder()
|
||||||
@@ -87,7 +105,8 @@ def start_decoder():
|
|||||||
if decoder.is_running:
|
if decoder.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'frequency': ISS_SSTV_FREQ
|
'frequency': ISS_SSTV_FREQ,
|
||||||
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
@@ -101,6 +120,8 @@ def start_decoder():
|
|||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
|
latitude = data.get('latitude')
|
||||||
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
# Validate frequency
|
# Validate frequency
|
||||||
try:
|
try:
|
||||||
@@ -116,17 +137,68 @@ def start_decoder():
|
|||||||
'message': 'Invalid frequency'
|
'message': 'Invalid frequency'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# Validate location if provided
|
||||||
|
if latitude is not None and longitude is not None:
|
||||||
|
try:
|
||||||
|
latitude = float(latitude)
|
||||||
|
longitude = float(longitude)
|
||||||
|
if not (-90 <= latitude <= 90):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Latitude must be between -90 and 90'
|
||||||
|
}), 400
|
||||||
|
if not (-180 <= longitude <= 180):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Longitude must be between -180 and 180'
|
||||||
|
}), 400
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid latitude or longitude'
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
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
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
success = decoder.start(frequency=frequency, device_index=device_index)
|
success = decoder.start(
|
||||||
|
frequency=frequency,
|
||||||
|
device_index=device_index,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude
|
||||||
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
sstv_active_device = device_int
|
||||||
|
|
||||||
|
result = {
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'device': device_index
|
'device': device_index,
|
||||||
})
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include initial Doppler info if available
|
||||||
|
if decoder.doppler_enabled and decoder.last_doppler_info:
|
||||||
|
result['doppler'] = decoder.last_doppler_info.to_dict()
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
else:
|
else:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder'
|
'message': 'Failed to start decoder'
|
||||||
@@ -141,11 +213,51 @@ def stop_decoder():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON confirmation.
|
JSON confirmation.
|
||||||
"""
|
"""
|
||||||
|
global sstv_active_device
|
||||||
decoder = get_sstv_decoder()
|
decoder = get_sstv_decoder()
|
||||||
decoder.stop()
|
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'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_bp.route('/doppler')
|
||||||
|
def get_doppler():
|
||||||
|
"""
|
||||||
|
Get current Doppler shift information.
|
||||||
|
|
||||||
|
Returns real-time Doppler shift data if tracking is enabled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with Doppler shift information.
|
||||||
|
"""
|
||||||
|
decoder = get_sstv_decoder()
|
||||||
|
|
||||||
|
if not decoder.doppler_enabled:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'disabled',
|
||||||
|
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
|
||||||
|
})
|
||||||
|
|
||||||
|
doppler_info = decoder.last_doppler_info
|
||||||
|
if not doppler_info:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': 'Doppler data not yet available'
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'doppler': doppler_info.to_dict(),
|
||||||
|
'nominal_frequency_mhz': ISS_SSTV_FREQ,
|
||||||
|
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/images')
|
@sstv_bp.route('/images')
|
||||||
def list_images():
|
def list_images():
|
||||||
"""
|
"""
|
||||||
@@ -200,6 +312,73 @@ def get_image(filename: str):
|
|||||||
return send_file(image_path, mimetype='image/png')
|
return send_file(image_path, mimetype='image/png')
|
||||||
|
|
||||||
|
|
||||||
|
@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')
|
@sstv_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""
|
"""
|
||||||
@@ -221,6 +400,10 @@ def stream_progress():
|
|||||||
try:
|
try:
|
||||||
progress = _sstv_queue.get(timeout=1)
|
progress = _sstv_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sstv', progress, progress.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -380,33 +563,7 @@ def iss_position():
|
|||||||
observer_lat = request.args.get('latitude', type=float)
|
observer_lat = request.args.get('latitude', type=float)
|
||||||
observer_lon = request.args.get('longitude', type=float)
|
observer_lon = request.args.get('longitude', type=float)
|
||||||
|
|
||||||
# Try primary API: Open Notify
|
# Try primary API: Where The ISS At
|
||||||
try:
|
|
||||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
if data.get('message') == 'success':
|
|
||||||
iss_lat = float(data['iss_position']['latitude'])
|
|
||||||
iss_lon = float(data['iss_position']['longitude'])
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'status': 'ok',
|
|
||||||
'lat': iss_lat,
|
|
||||||
'lon': iss_lon,
|
|
||||||
'altitude': 420, # Approximate ISS altitude in km
|
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
|
||||||
'source': 'open-notify'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate observer-relative data if location provided
|
|
||||||
if observer_lat is not None and observer_lon is not None:
|
|
||||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
|
||||||
|
|
||||||
return jsonify(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Open Notify API failed: {e}")
|
|
||||||
|
|
||||||
# Try fallback API: Where The ISS At
|
|
||||||
try:
|
try:
|
||||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -431,6 +588,32 @@ def iss_position():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Where The ISS At API failed: {e}")
|
logger.warning(f"Where The ISS At API failed: {e}")
|
||||||
|
|
||||||
|
# Try fallback API: Open Notify
|
||||||
|
try:
|
||||||
|
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('message') == 'success':
|
||||||
|
iss_lat = float(data['iss_position']['latitude'])
|
||||||
|
iss_lon = float(data['iss_position']['longitude'])
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'status': 'ok',
|
||||||
|
'lat': iss_lat,
|
||||||
|
'lon': iss_lon,
|
||||||
|
'altitude': 420, # Approximate ISS altitude in km
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'source': 'open-notify'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate observer-relative data if location provided
|
||||||
|
if observer_lat is not None and observer_lon is not None:
|
||||||
|
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Open Notify API failed: {e}")
|
||||||
|
|
||||||
# Both APIs failed
|
# Both APIs failed
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||||
|
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Set callback and start
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
success = decoder.start(
|
||||||
|
frequency=frequency,
|
||||||
|
device_index=device_index,
|
||||||
|
modulation=modulation,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'modulation': modulation,
|
||||||
|
'device': device_index,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start decoder',
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_decoder():
|
||||||
|
"""Stop general SSTV decoder."""
|
||||||
|
decoder = get_general_sstv_decoder()
|
||||||
|
decoder.stop()
|
||||||
|
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 generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
progress = _sstv_general_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sstv_general', progress, progress.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(progress)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.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,424 @@
|
|||||||
|
"""SubGHz transceiver routes.
|
||||||
|
|
||||||
|
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
|
||||||
|
signal replay/transmit, and wideband spectrum analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import sse_stream
|
||||||
|
from utils.subghz import get_subghz_manager
|
||||||
|
from utils.constants import (
|
||||||
|
SUBGHZ_FREQ_MIN_MHZ,
|
||||||
|
SUBGHZ_FREQ_MAX_MHZ,
|
||||||
|
SUBGHZ_LNA_GAIN_MAX,
|
||||||
|
SUBGHZ_VGA_GAIN_MAX,
|
||||||
|
SUBGHZ_TX_VGA_GAIN_MAX,
|
||||||
|
SUBGHZ_TX_MAX_DURATION,
|
||||||
|
SUBGHZ_SAMPLE_RATES,
|
||||||
|
SUBGHZ_PRESETS,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.subghz')
|
||||||
|
|
||||||
|
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
||||||
|
|
||||||
|
# SSE queue for streaming events to frontend
|
||||||
|
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_callback(event: dict) -> None:
|
||||||
|
"""Forward SubGhzManager events to the SSE queue."""
|
||||||
|
try:
|
||||||
|
_subghz_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_subghz_queue.get_nowait()
|
||||||
|
_subghz_queue.put_nowait(event)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
|
||||||
|
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
|
||||||
|
raw = data.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return None, f'{key} is required'
|
||||||
|
try:
|
||||||
|
freq_hz = int(raw)
|
||||||
|
freq_mhz = freq_hz / 1_000_000
|
||||||
|
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
|
||||||
|
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
|
||||||
|
return freq_hz, None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None, f'Invalid {key}'
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_serial(data: dict) -> str | None:
|
||||||
|
"""Extract and validate optional HackRF device serial."""
|
||||||
|
serial = data.get('device_serial', '')
|
||||||
|
if not serial or not isinstance(serial, str):
|
||||||
|
return None
|
||||||
|
# HackRF serials are hex strings
|
||||||
|
serial = serial.strip()
|
||||||
|
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
|
||||||
|
return serial
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||||
|
"""Validate integer parameter with bounds clamping."""
|
||||||
|
try:
|
||||||
|
val = int(data.get(key, default))
|
||||||
|
return max(min_val, min(max_val, val))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||||
|
profile = data.get('decode_profile', default)
|
||||||
|
if not isinstance(profile, str):
|
||||||
|
return default
|
||||||
|
profile = profile.strip().lower()
|
||||||
|
if profile in {'weather', 'all'}:
|
||||||
|
return profile
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||||
|
raw = data.get(key)
|
||||||
|
if raw is None or raw == '':
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
return float(raw), None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None, f'Invalid {key}'
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||||
|
raw = data.get(key, default)
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
return bool(raw)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# STATUS
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@subghz_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
return jsonify(manager.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/presets')
|
||||||
|
def get_presets():
|
||||||
|
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# RECEIVE
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@subghz_bp.route('/receive/start', methods=['POST'])
|
||||||
|
def start_receive():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
freq_hz, err = _validate_frequency_hz(data)
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
|
||||||
|
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||||
|
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||||
|
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||||
|
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||||
|
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||||
|
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||||
|
device_serial = _validate_serial(data)
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
manager.set_callback(_event_callback)
|
||||||
|
|
||||||
|
result = manager.start_receive(
|
||||||
|
frequency_hz=freq_hz,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
lna_gain=lna_gain,
|
||||||
|
vga_gain=vga_gain,
|
||||||
|
trigger_enabled=trigger_enabled,
|
||||||
|
trigger_pre_ms=trigger_pre_ms,
|
||||||
|
trigger_post_ms=trigger_post_ms,
|
||||||
|
device_serial=device_serial,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code = 200 if result.get('status') != 'error' else 409
|
||||||
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/receive/stop', methods=['POST'])
|
||||||
|
def stop_receive():
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
result = manager.stop_receive()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DECODE
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@subghz_bp.route('/decode/start', methods=['POST'])
|
||||||
|
def start_decode():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
freq_hz, err = _validate_frequency_hz(data)
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
|
||||||
|
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||||
|
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||||
|
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||||
|
decode_profile = _validate_decode_profile(data)
|
||||||
|
device_serial = _validate_serial(data)
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
manager.set_callback(_event_callback)
|
||||||
|
|
||||||
|
result = manager.start_decode(
|
||||||
|
frequency_hz=freq_hz,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
lna_gain=lna_gain,
|
||||||
|
vga_gain=vga_gain,
|
||||||
|
decode_profile=decode_profile,
|
||||||
|
device_serial=device_serial,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code = 200 if result.get('status') != 'error' else 409
|
||||||
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/decode/stop', methods=['POST'])
|
||||||
|
def stop_decode():
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
result = manager.stop_decode()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# TRANSMIT
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@subghz_bp.route('/transmit', methods=['POST'])
|
||||||
|
def start_transmit():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
capture_id = data.get('capture_id')
|
||||||
|
if not capture_id or not isinstance(capture_id, str):
|
||||||
|
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
|
||||||
|
|
||||||
|
# Sanitize capture_id
|
||||||
|
if not capture_id.isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||||
|
|
||||||
|
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
||||||
|
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||||
|
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||||
|
if start_err:
|
||||||
|
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||||
|
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||||
|
if duration_err:
|
||||||
|
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||||
|
device_serial = _validate_serial(data)
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
manager.set_callback(_event_callback)
|
||||||
|
|
||||||
|
result = manager.transmit(
|
||||||
|
capture_id=capture_id,
|
||||||
|
tx_gain=tx_gain,
|
||||||
|
max_duration=max_duration,
|
||||||
|
start_seconds=start_seconds,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
device_serial=device_serial,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code = 200 if result.get('status') != 'error' else 400
|
||||||
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/transmit/stop', methods=['POST'])
|
||||||
|
def stop_transmit():
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
result = manager.stop_transmit()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SWEEP
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@subghz_bp.route('/sweep/start', methods=['POST'])
|
||||||
|
def start_sweep():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
freq_start = float(data.get('freq_start_mhz', 300))
|
||||||
|
freq_end = float(data.get('freq_end_mhz', 928))
|
||||||
|
if freq_start >= freq_end:
|
||||||
|
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
|
||||||
|
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
|
||||||
|
|
||||||
|
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
||||||
|
device_serial = _validate_serial(data)
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
manager.set_callback(_event_callback)
|
||||||
|
|
||||||
|
result = manager.start_sweep(
|
||||||
|
freq_start_mhz=freq_start,
|
||||||
|
freq_end_mhz=freq_end,
|
||||||
|
bin_width=bin_width,
|
||||||
|
device_serial=device_serial,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code = 200 if result.get('status') != 'error' else 409
|
||||||
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/sweep/stop', methods=['POST'])
|
||||||
|
def stop_sweep():
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
result = manager.stop_sweep()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CAPTURES LIBRARY
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@subghz_bp.route('/captures')
|
||||||
|
def list_captures():
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
captures = manager.list_captures()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'captures': [c.to_dict() for c in captures],
|
||||||
|
'count': len(captures),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/captures/<capture_id>')
|
||||||
|
def get_capture(capture_id: str):
|
||||||
|
if not capture_id.isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
capture = manager.get_capture(capture_id)
|
||||||
|
if not capture:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/captures/<capture_id>/download')
|
||||||
|
def download_capture(capture_id: str):
|
||||||
|
if not capture_id.isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
path = manager.get_capture_path(capture_id)
|
||||||
|
if not path:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
path,
|
||||||
|
mimetype='application/octet-stream',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||||
|
def trim_capture(capture_id: str):
|
||||||
|
if not capture_id.isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||||
|
if start_err:
|
||||||
|
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||||
|
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||||
|
if duration_err:
|
||||||
|
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||||
|
|
||||||
|
label = data.get('label', '')
|
||||||
|
if label is None:
|
||||||
|
label = ''
|
||||||
|
if not isinstance(label, str) or len(label) > 100:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
result = manager.trim_capture(
|
||||||
|
capture_id=capture_id,
|
||||||
|
start_seconds=start_seconds,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get('status') == 'ok':
|
||||||
|
return jsonify(result), 200
|
||||||
|
message = str(result.get('message') or 'Trim failed')
|
||||||
|
status_code = 404 if 'not found' in message.lower() else 400
|
||||||
|
return jsonify(result), status_code
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||||
|
def delete_capture(capture_id: str):
|
||||||
|
if not capture_id.isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
if manager.delete_capture(capture_id):
|
||||||
|
return jsonify({'status': 'deleted', 'id': capture_id})
|
||||||
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
||||||
|
def update_capture(capture_id: str):
|
||||||
|
if not capture_id.isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
label = data.get('label', '')
|
||||||
|
|
||||||
|
if not isinstance(label, str) or len(label) > 100:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||||
|
|
||||||
|
manager = get_subghz_manager()
|
||||||
|
if manager.update_capture_label(capture_id, label):
|
||||||
|
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
||||||
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SSE STREAM
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@subghz_bp.route('/stream')
|
||||||
|
def stream():
|
||||||
|
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
+885
-211
File diff suppressed because it is too large
Load Diff
+42
-2
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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.logging import get_logger
|
||||||
from utils.updater import (
|
from utils.updater import (
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
get_update_status,
|
|
||||||
dismiss_update,
|
dismiss_update,
|
||||||
|
get_update_status,
|
||||||
perform_update,
|
perform_update,
|
||||||
|
restart_application,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.routes.updater')
|
logger = get_logger('intercept.routes.updater')
|
||||||
@@ -137,3 +138,42 @@ def dismiss_notification() -> Response:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}), 500
|
}), 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,386 @@
|
|||||||
|
"""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 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 safe_terminate, register_process, unregister_process
|
||||||
|
from utils.waterfall_fft import (
|
||||||
|
build_binary_frame,
|
||||||
|
compute_power_spectrum,
|
||||||
|
cu8_to_complex,
|
||||||
|
quantize_to_uint8,
|
||||||
|
)
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||||
|
|
||||||
|
logger = get_logger('intercept.waterfall_ws')
|
||||||
|
|
||||||
|
# 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 _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
|
||||||
|
# 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.1)
|
||||||
|
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
|
||||||
|
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
|
||||||
|
center_freq = float(data.get('center_freq', 100.0))
|
||||||
|
span_mhz = float(data.get('span_mhz', 2.0))
|
||||||
|
gain = data.get('gain')
|
||||||
|
if gain is not None:
|
||||||
|
gain = float(gain)
|
||||||
|
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))
|
||||||
|
|
||||||
|
# Clamp FFT size to valid powers of 2
|
||||||
|
fft_size = max(256, min(8192, fft_size))
|
||||||
|
|
||||||
|
# Resolve SDR type and bandwidth
|
||||||
|
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||||
|
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||||
|
span_hz = int(span_mhz * 1e6)
|
||||||
|
sample_rate = min(span_hz, max_bw)
|
||||||
|
|
||||||
|
# Compute effective frequency range
|
||||||
|
effective_span_mhz = sample_rate / 1e6
|
||||||
|
start_freq = center_freq - effective_span_mhz / 2
|
||||||
|
end_freq = center_freq + 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:
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device = _build_dummy_device(device_index, sdr_type)
|
||||||
|
iq_cmd = builder.build_iq_capture_command(
|
||||||
|
device=device,
|
||||||
|
frequency_mhz=center_freq,
|
||||||
|
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, "
|
||||||
|
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
|
||||||
|
|
||||||
|
# Send started confirmation
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'started',
|
||||||
|
'start_freq': start_freq,
|
||||||
|
'end_freq': end_freq,
|
||||||
|
'fft_size': fft_size,
|
||||||
|
'sample_rate': sample_rate,
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
|
def fft_reader(
|
||||||
|
proc, _send_q, stop_evt,
|
||||||
|
_fft_size, _avg_count, _fps,
|
||||||
|
_start_freq, _end_freq,
|
||||||
|
):
|
||||||
|
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||||
|
bytes_per_frame = _fft_size * _avg_count * 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)
|
||||||
|
power_db = compute_power_spectrum(
|
||||||
|
samples,
|
||||||
|
fft_size=_fft_size,
|
||||||
|
avg_count=_avg_count,
|
||||||
|
)
|
||||||
|
quantized = quantize_to_uint8(power_db)
|
||||||
|
frame = build_binary_frame(
|
||||||
|
_start_freq, _end_freq, quantized,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_send_q.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
# Drop frame if main loop can't keep up
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
start_freq, end_freq,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
reader_thread.start()
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
# 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").
|
||||||
|
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 waterfall client disconnected")
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"""Weather Satellite decoder routes.
|
||||||
|
|
||||||
|
Provides endpoints for capturing and decoding weather satellite images
|
||||||
|
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import sse_stream
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
|
||||||
|
from utils.weather_sat import (
|
||||||
|
get_weather_sat_decoder,
|
||||||
|
is_weather_sat_available,
|
||||||
|
CaptureProgress,
|
||||||
|
WEATHER_SATELLITES,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.weather_sat')
|
||||||
|
|
||||||
|
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||||
|
|
||||||
|
# Queue for SSE progress streaming
|
||||||
|
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_callback(progress: CaptureProgress) -> None:
|
||||||
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
"""Get weather satellite decoder status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with decoder availability and current status.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
return jsonify(decoder.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/satellites')
|
||||||
|
def list_satellites():
|
||||||
|
"""Get list of supported weather satellites with frequencies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with satellite definitions.
|
||||||
|
"""
|
||||||
|
satellites = []
|
||||||
|
for key, info in WEATHER_SATELLITES.items():
|
||||||
|
satellites.append({
|
||||||
|
'key': key,
|
||||||
|
'name': info['name'],
|
||||||
|
'frequency': info['frequency'],
|
||||||
|
'mode': info['mode'],
|
||||||
|
'description': info['description'],
|
||||||
|
'active': info['active'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'satellites': satellites,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/start', methods=['POST'])
|
||||||
|
def start_capture():
|
||||||
|
"""Start weather satellite capture and decode.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"satellite": "NOAA-18", // Required: satellite key
|
||||||
|
"device": 0, // RTL-SDR device index (default: 0)
|
||||||
|
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||||
|
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
if not is_weather_sat_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'satellite': decoder.current_satellite,
|
||||||
|
'frequency': decoder.current_frequency,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Validate satellite
|
||||||
|
satellite = data.get('satellite')
|
||||||
|
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate device index and gain
|
||||||
|
try:
|
||||||
|
device_index = validate_device_index(data.get('device', 0))
|
||||||
|
gain = validate_gain(data.get('gain', 40.0))
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning('Invalid parameter in start_capture: %s', e)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid parameter value'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error,
|
||||||
|
}), 409
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not _weather_sat_queue.empty():
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Set callback and on-complete handler for SDR release
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
|
||||||
|
def _release_device():
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
decoder.set_on_complete(_release_device)
|
||||||
|
|
||||||
|
success = decoder.start(
|
||||||
|
satellite=satellite,
|
||||||
|
device_index=device_index,
|
||||||
|
gain=gain,
|
||||||
|
bias_t=bias_t,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
sat_info = WEATHER_SATELLITES[satellite]
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'satellite': satellite,
|
||||||
|
'frequency': sat_info['frequency'],
|
||||||
|
'mode': sat_info['mode'],
|
||||||
|
'device': device_index,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Release device on failure
|
||||||
|
_release_device()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start capture'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/test-decode', methods=['POST'])
|
||||||
|
def test_decode():
|
||||||
|
"""Start weather satellite decode from a pre-recorded file.
|
||||||
|
|
||||||
|
No SDR hardware is required — decodes an IQ baseband or WAV file
|
||||||
|
using SatDump offline mode.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"satellite": "NOAA-18", // Required: satellite key
|
||||||
|
"input_file": "/path/to/file", // Required: server-side file path
|
||||||
|
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
if not is_weather_sat_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'satellite': decoder.current_satellite,
|
||||||
|
'frequency': decoder.current_frequency,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Validate satellite
|
||||||
|
satellite = data.get('satellite')
|
||||||
|
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate input file
|
||||||
|
input_file = data.get('input_file')
|
||||||
|
if not input_file:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'input_file is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
input_path = Path(input_file)
|
||||||
|
|
||||||
|
# Security: restrict to data directory (anchored to app root, not CWD)
|
||||||
|
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||||
|
try:
|
||||||
|
resolved = input_path.resolve()
|
||||||
|
if not resolved.is_relative_to(allowed_base):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'input_file must be under the data/ directory'
|
||||||
|
}), 403
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid file path'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not input_path.is_file():
|
||||||
|
logger.warning("Test-decode file not found")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'File not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# Validate sample rate
|
||||||
|
sample_rate = data.get('sample_rate', 1000000)
|
||||||
|
try:
|
||||||
|
sample_rate = int(sample_rate)
|
||||||
|
if sample_rate < 1000 or sample_rate > 20000000:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid sample_rate (1000-20000000)'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not _weather_sat_queue.empty():
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Set callback — no on_complete needed (no SDR to release)
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
decoder.set_on_complete(None)
|
||||||
|
|
||||||
|
success = decoder.start_from_file(
|
||||||
|
satellite=satellite,
|
||||||
|
input_file=input_file,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
sat_info = WEATHER_SATELLITES[satellite]
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'satellite': satellite,
|
||||||
|
'frequency': sat_info['frequency'],
|
||||||
|
'mode': sat_info['mode'],
|
||||||
|
'source': 'file',
|
||||||
|
'input_file': str(input_file),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start file decode'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_capture():
|
||||||
|
"""Stop weather satellite capture.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
device_index = decoder.device_index
|
||||||
|
|
||||||
|
decoder.stop()
|
||||||
|
|
||||||
|
# Release SDR device
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images')
|
||||||
|
def list_images():
|
||||||
|
"""Get list of decoded weather satellite images.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
limit: Maximum number of images (default: all)
|
||||||
|
satellite: Filter by satellite key (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of decoded images.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
images = decoder.get_images()
|
||||||
|
|
||||||
|
# Filter by satellite if specified
|
||||||
|
satellite_filter = request.args.get('satellite')
|
||||||
|
if satellite_filter:
|
||||||
|
images = [img for img in images if img.satellite == satellite_filter]
|
||||||
|
|
||||||
|
# Apply limit
|
||||||
|
limit = request.args.get('limit', type=int)
|
||||||
|
if limit and limit > 0:
|
||||||
|
images = images[-limit:]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'images': [img.to_dict() for img in images],
|
||||||
|
'count': len(images),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images/<filename>')
|
||||||
|
def get_image(filename: str):
|
||||||
|
"""Serve a decoded weather satellite image file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image file or 404.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
# Security: only allow safe filenames
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
||||||
|
|
||||||
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||||
|
return send_file(image_path, mimetype=mimetype)
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||||
|
def delete_image(filename: str):
|
||||||
|
"""Delete a decoded image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if decoder.delete_image(filename):
|
||||||
|
return jsonify({'status': 'deleted', 'filename': filename})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||||
|
def delete_all_images():
|
||||||
|
"""Delete all decoded weather satellite images.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with count of deleted images.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
count = decoder.delete_all_images()
|
||||||
|
return jsonify({'status': 'ok', 'deleted': count})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/stream')
|
||||||
|
def stream_progress():
|
||||||
|
"""SSE stream of capture/decode progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE stream (text/event-stream)
|
||||||
|
"""
|
||||||
|
response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/passes')
|
||||||
|
def get_passes():
|
||||||
|
"""Get upcoming weather satellite passes for observer location.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
latitude: Observer latitude (required)
|
||||||
|
longitude: Observer longitude (required)
|
||||||
|
hours: Hours to predict ahead (default: 24, max: 72)
|
||||||
|
min_elevation: Minimum elevation in degrees (default: 15)
|
||||||
|
trajectory: Include az/el trajectory points (default: false)
|
||||||
|
ground_track: Include lat/lon ground track points (default: false)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with upcoming passes for all weather satellites.
|
||||||
|
"""
|
||||||
|
include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
|
||||||
|
include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
|
||||||
|
|
||||||
|
raw_lat = request.args.get('latitude')
|
||||||
|
raw_lon = request.args.get('longitude')
|
||||||
|
|
||||||
|
if raw_lat is None or raw_lon is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'latitude and longitude parameters required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = validate_latitude(raw_lat)
|
||||||
|
lon = validate_longitude(raw_lon)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning('Invalid coordinates in get_passes: %s', e)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||||
|
|
||||||
|
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
||||||
|
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.weather_sat_predict import predict_passes
|
||||||
|
|
||||||
|
all_passes = predict_passes(
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
hours=hours,
|
||||||
|
min_elevation=min_elevation,
|
||||||
|
include_trajectory=include_trajectory,
|
||||||
|
include_ground_track=include_ground_track,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'passes': all_passes,
|
||||||
|
'count': len(all_passes),
|
||||||
|
'observer': {'latitude': lat, 'longitude': lon},
|
||||||
|
'prediction_hours': hours,
|
||||||
|
'min_elevation': min_elevation,
|
||||||
|
})
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'skyfield library not installed'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error predicting passes: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Pass prediction failed'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Auto-Scheduler Endpoints
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduler_event_callback(event: dict) -> None:
|
||||||
|
"""Forward scheduler events to the SSE queue."""
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
_weather_sat_queue.put_nowait(event)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/enable', methods=['POST'])
|
||||||
|
def enable_schedule():
|
||||||
|
"""Enable auto-scheduling of weather satellite captures.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"latitude": 51.5, // Required
|
||||||
|
"longitude": -0.1, // Required
|
||||||
|
"min_elevation": 15, // Minimum pass elevation (default: 15)
|
||||||
|
"device": 0, // RTL-SDR device index (default: 0)
|
||||||
|
"gain": 40.0, // SDR gain (default: 40)
|
||||||
|
"bias_t": false // Enable bias-T (default: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with scheduler status.
|
||||||
|
"""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if data.get('latitude') is None or data.get('longitude') is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'latitude and longitude required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = validate_latitude(data.get('latitude'))
|
||||||
|
lon = validate_longitude(data.get('longitude'))
|
||||||
|
min_elev = validate_elevation(data.get('min_elevation', 15))
|
||||||
|
device = validate_device_index(data.get('device', 0))
|
||||||
|
gain_val = validate_gain(data.get('gain', 40.0))
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning('Invalid parameter in enable_schedule: %s', e)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid parameter value'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||||
|
|
||||||
|
result = scheduler.enable(
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
min_elevation=min_elev,
|
||||||
|
device=device,
|
||||||
|
gain=gain_val,
|
||||||
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', **result})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||||
|
def disable_schedule():
|
||||||
|
"""Disable auto-scheduling."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
result = scheduler.disable()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/status')
|
||||||
|
def schedule_status():
|
||||||
|
"""Get current scheduler state."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
return jsonify(scheduler.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/passes')
|
||||||
|
def schedule_passes():
|
||||||
|
"""List scheduled passes."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
passes = scheduler.get_passes()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'passes': passes,
|
||||||
|
'count': len(passes),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/schedule/skip/<pass_id>', methods=['POST'])
|
||||||
|
def skip_pass(pass_id: str):
|
||||||
|
"""Skip a scheduled pass."""
|
||||||
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||||
|
|
||||||
|
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
|
||||||
|
|
||||||
|
scheduler = get_weather_sat_scheduler()
|
||||||
|
if scheduler.skip_pass(pass_id):
|
||||||
|
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
|
||||||
@@ -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")
|
||||||
+229
-6
@@ -21,6 +21,7 @@ from utils.logging import wifi_logger as logger
|
|||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
WIFI_TERMINATE_TIMEOUT,
|
WIFI_TERMINATE_TIMEOUT,
|
||||||
@@ -50,6 +51,31 @@ pmkid_process = None
|
|||||||
pmkid_lock = threading.Lock()
|
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():
|
def detect_wifi_interfaces():
|
||||||
"""Detect available WiFi interfaces."""
|
"""Detect available WiFi interfaces."""
|
||||||
interfaces = []
|
interfaces = []
|
||||||
@@ -608,6 +634,7 @@ def start_wifi_scan():
|
|||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
|
channels = data.get('channels')
|
||||||
band = data.get('band', 'abg')
|
band = data.get('band', 'abg')
|
||||||
|
|
||||||
# Use provided interface or fall back to stored monitor interface
|
# Use provided interface or fall back to stored monitor interface
|
||||||
@@ -658,7 +685,16 @@ def start_wifi_scan():
|
|||||||
interface
|
interface
|
||||||
]
|
]
|
||||||
|
|
||||||
if 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)])
|
cmd.extend(['-c', str(channel)])
|
||||||
|
|
||||||
logger.info(f"Running: {' '.join(cmd)}")
|
logger.info(f"Running: {' '.join(cmd)}")
|
||||||
@@ -852,6 +888,9 @@ def check_handshake_status():
|
|||||||
|
|
||||||
file_size = os.path.getsize(capture_file)
|
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:
|
try:
|
||||||
if target_bssid and is_valid_mac(target_bssid):
|
if target_bssid and is_valid_mac(target_bssid):
|
||||||
@@ -862,20 +901,38 @@ def check_handshake_status():
|
|||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=10
|
||||||
)
|
)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
output_lower = output.lower()
|
||||||
if '0 handshake' not in output:
|
handshake_checked = True
|
||||||
handshake_found = 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:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking handshake: {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({
|
return jsonify({
|
||||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||||
'file_exists': True,
|
'file_exists': True,
|
||||||
'file_size': file_size,
|
'file_size': file_size,
|
||||||
'file': capture_file,
|
'file': capture_file,
|
||||||
'handshake_found': handshake_found
|
'handshake_found': handshake_found,
|
||||||
|
'handshake_valid': handshake_valid,
|
||||||
|
'handshake_checked': handshake_checked,
|
||||||
|
'handshake_reason': handshake_reason
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1086,6 +1143,10 @@ def stream_wifi():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.wifi_queue.get(timeout=1)
|
msg = app_module.wifi_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('wifi', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1240,10 +1301,32 @@ def v2_get_networks():
|
|||||||
|
|
||||||
@wifi_bp.route('/v2/clients')
|
@wifi_bp.route('/v2/clients')
|
||||||
def v2_get_clients():
|
def v2_get_clients():
|
||||||
"""Get all discovered clients."""
|
"""Get discovered clients with optional filtering."""
|
||||||
try:
|
try:
|
||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
clients = scanner.clients
|
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({
|
return jsonify({
|
||||||
'clients': [c.to_dict() for c in clients],
|
'clients': [c.to_dict() for c in clients],
|
||||||
'total': len(clients),
|
'total': len(clients),
|
||||||
@@ -1413,3 +1496,143 @@ def v2_clear_data():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error clearing data")
|
logger.exception("Error clearing data")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# V2 Deauth Detection Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/status')
|
||||||
|
def v2_deauth_status():
|
||||||
|
"""
|
||||||
|
Get deauth detection status and recent alerts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- is_running: Whether deauth detector is active
|
||||||
|
- interface: Monitor interface being used
|
||||||
|
- stats: Detection statistics
|
||||||
|
- recent_alerts: Recent deauth alerts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
detector = scanner.deauth_detector
|
||||||
|
|
||||||
|
if detector:
|
||||||
|
stats = detector.stats
|
||||||
|
alerts = detector.get_alerts(limit=50)
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
'is_running': False,
|
||||||
|
'interface': None,
|
||||||
|
'packets_captured': 0,
|
||||||
|
'alerts_generated': 0,
|
||||||
|
}
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'is_running': stats.get('is_running', False),
|
||||||
|
'interface': stats.get('interface'),
|
||||||
|
'started_at': stats.get('started_at'),
|
||||||
|
'stats': {
|
||||||
|
'packets_captured': stats.get('packets_captured', 0),
|
||||||
|
'alerts_generated': stats.get('alerts_generated', 0),
|
||||||
|
'active_trackers': stats.get('active_trackers', 0),
|
||||||
|
},
|
||||||
|
'recent_alerts': alerts,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting deauth status")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/stream')
|
||||||
|
def v2_deauth_stream():
|
||||||
|
"""
|
||||||
|
SSE stream for real-time deauth alerts.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- deauth_alert: A deauth attack was detected
|
||||||
|
- deauth_detector_started: Detector started
|
||||||
|
- deauth_detector_stopped: Detector stopped
|
||||||
|
- 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'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/alerts')
|
||||||
|
def v2_deauth_alerts():
|
||||||
|
"""
|
||||||
|
Get historical deauth alerts.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- limit: Maximum number of alerts to return (default 100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000
|
||||||
|
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
alerts = scanner.get_deauth_alerts(limit=limit)
|
||||||
|
|
||||||
|
# Also include alerts from DataStore that might have been persisted
|
||||||
|
try:
|
||||||
|
stored_alerts = list(app_module.deauth_alerts.values())
|
||||||
|
# Merge and deduplicate by ID
|
||||||
|
alert_ids = {a.get('id') for a in alerts}
|
||||||
|
for alert in stored_alerts:
|
||||||
|
if alert.get('id') not in alert_ids:
|
||||||
|
alerts.append(alert)
|
||||||
|
# Sort by timestamp descending
|
||||||
|
alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True)
|
||||||
|
alerts = alerts[:limit]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'alerts': alerts,
|
||||||
|
'count': len(alerts),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error getting deauth alerts")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
|
||||||
|
def v2_deauth_clear():
|
||||||
|
"""Clear deauth alert history."""
|
||||||
|
try:
|
||||||
|
scanner = get_wifi_scanner()
|
||||||
|
scanner.clear_deauth_alerts()
|
||||||
|
|
||||||
|
# Clear the queue
|
||||||
|
while not app_module.deauth_detector_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.deauth_detector_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error clearing deauth alerts")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
+23
-1
@@ -24,6 +24,8 @@ from utils.wifi import (
|
|||||||
SCAN_MODE_DEEP,
|
SCAN_MODE_DEEP,
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.validation import validate_wifi_channel
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -89,15 +91,30 @@ def start_deep_scan():
|
|||||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||||
band: Band to scan ('2.4', '5', 'all')
|
band: Band to scan ('2.4', '5', 'all')
|
||||||
channel: Optional specific channel to monitor
|
channel: Optional specific channel to monitor
|
||||||
|
channels: Optional list or comma-separated channels to monitor
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
interface = data.get('interface')
|
interface = data.get('interface')
|
||||||
band = data.get('band', 'all')
|
band = data.get('band', 'all')
|
||||||
channel = data.get('channel')
|
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:
|
if channel:
|
||||||
try:
|
try:
|
||||||
channel = int(channel)
|
channel = validate_wifi_channel(channel)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid channel'}), 400
|
return jsonify({'error': 'Invalid channel'}), 400
|
||||||
|
|
||||||
@@ -106,6 +123,7 @@ def start_deep_scan():
|
|||||||
interface=interface,
|
interface=interface,
|
||||||
band=band,
|
band=band,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
channels=channel_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -391,6 +409,10 @@ def event_stream():
|
|||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
|
|
||||||
for event in scanner.get_event_stream():
|
for event in scanner.get_event_stream():
|
||||||
|
try:
|
||||||
|
process_event('wifi', event, event.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(event)
|
yield format_sse(event)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ need_sudo() {
|
|||||||
fi
|
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() {
|
detect_os() {
|
||||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||||
OS="macos"
|
OS="macos"
|
||||||
@@ -165,6 +173,7 @@ detect_dragonos() {
|
|||||||
# Required tool checks (with alternates)
|
# Required tool checks (with alternates)
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
missing_required=()
|
missing_required=()
|
||||||
|
missing_recommended=()
|
||||||
|
|
||||||
check_required() {
|
check_required() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
@@ -178,6 +187,18 @@ check_required() {
|
|||||||
fi
|
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() {
|
check_optional() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
local desc="$1"; shift
|
local desc="$1"; shift
|
||||||
@@ -201,14 +222,20 @@ check_tools() {
|
|||||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||||
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
|
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 "dump1090" "ADS-B decoder" dump1090
|
||||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
|
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||||
echo
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
check_required "gpsd" "GPS daemon" gpsd
|
check_required "gpsd" "GPS daemon" gpsd
|
||||||
|
|
||||||
|
echo
|
||||||
|
info "Digital Voice:"
|
||||||
|
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
|
||||||
|
|
||||||
echo
|
echo
|
||||||
info "Audio:"
|
info "Audio:"
|
||||||
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||||
@@ -369,7 +396,7 @@ install_rtlamr_from_source() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
else
|
else
|
||||||
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
@@ -385,6 +412,7 @@ install_rtlamr_from_source() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
install_multimon_ng_from_source_macos() {
|
install_multimon_ng_from_source_macos() {
|
||||||
info "multimon-ng not available via Homebrew. Building from source..."
|
info "multimon-ng not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
@@ -410,14 +438,369 @@ install_multimon_ng_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
else
|
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
|
fi
|
||||||
ok "multimon-ng installed successfully from source"
|
ok "multimon-ng installed successfully from source"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_dsd_from_source() {
|
||||||
|
info "Building DSD (Digital Speech Decoder) from source..."
|
||||||
|
info "This requires mbelib (vocoder library) as a prerequisite."
|
||||||
|
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
brew_install cmake
|
||||||
|
brew_install libsndfile
|
||||||
|
brew_install ncurses
|
||||||
|
brew_install fftw
|
||||||
|
brew_install codec2
|
||||||
|
brew_install librtlsdr
|
||||||
|
brew_install pulseaudio || true
|
||||||
|
else
|
||||||
|
apt_install build-essential git cmake libsndfile1-dev libpulse-dev \
|
||||||
|
libfftw3-dev liblapack-dev libncurses-dev librtlsdr-dev libcodec2-dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
# Step 1: Build and install mbelib (required dependency)
|
||||||
|
info "Building mbelib (vocoder library)..."
|
||||||
|
git clone https://github.com/lwvmobile/mbelib.git "$tmp_dir/mbelib" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone mbelib"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/mbelib"
|
||||||
|
git checkout ambe_tones >/dev/null 2>&1 || true
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
if cmake .. >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
if [[ -w /usr/local/lib ]]; then
|
||||||
|
make install >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
$SUDO ldconfig 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
ok "mbelib installed"
|
||||||
|
else
|
||||||
|
warn "Failed to build mbelib. Cannot build DSD without it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: Build dsd-fme (or fall back to original dsd)
|
||||||
|
info "Building dsd-fme..."
|
||||||
|
git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone dsd-fme, trying original DSD...";
|
||||||
|
git clone --depth 1 https://github.com/szechyjs/dsd.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone DSD"; exit 1; }; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/dsd-fme"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
# On macOS, help cmake find Homebrew ncurses
|
||||||
|
local cmake_flags=""
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
local ncurses_prefix
|
||||||
|
ncurses_prefix="$(brew --prefix ncurses 2>/dev/null || echo /opt/homebrew/opt/ncurses)"
|
||||||
|
cmake_flags="-DCMAKE_PREFIX_PATH=$ncurses_prefix"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Compiling DSD..."
|
||||||
|
if cmake .. $cmake_flags >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then
|
||||||
|
if [[ "$OS" == "macos" ]]; then
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
|
else
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
$SUDO make install >/dev/null 2>&1 \
|
||||||
|
|| $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null \
|
||||||
|
|| $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
$SUDO ldconfig 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
ok "DSD installed successfully"
|
||||||
|
else
|
||||||
|
warn "Failed to build DSD from source. DMR/P25 decoding will not be available."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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_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_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)..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake pkg-config \
|
||||||
|
libpng-dev libtiff-dev libjemalloc-dev libvolk-dev libnng-dev \
|
||||||
|
libzstd-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev \
|
||||||
|
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning SatDump v1.2.2..."
|
||||||
|
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/SatDump"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
||||||
|
build_log="$tmp_dir/satdump-build.log"
|
||||||
|
|
||||||
|
# Show periodic progress while building so the user knows it's not hung
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
sleep 30
|
||||||
|
if [ -f "$build_log" ]; then
|
||||||
|
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
|
||||||
|
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
progress_pid=$!
|
||||||
|
|
||||||
|
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >"$build_log" 2>&1 \
|
||||||
|
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
|
||||||
|
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
$SUDO ldconfig
|
||||||
|
|
||||||
|
# Ensure plugins are in the expected path (handles multiarch differences)
|
||||||
|
$SUDO mkdir -p /usr/local/lib/satdump/plugins
|
||||||
|
if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then
|
||||||
|
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do
|
||||||
|
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then
|
||||||
|
$SUDO ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "SatDump installed successfully."
|
||||||
|
else
|
||||||
|
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||||
|
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||||
|
warn "Build log (last 30 lines):"
|
||||||
|
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
install_satdump_from_source_macos() {
|
||||||
|
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||||
|
|
||||||
|
brew_install cmake
|
||||||
|
brew_install libpng
|
||||||
|
brew_install libtiff
|
||||||
|
brew_install jemalloc
|
||||||
|
brew_install libvolk
|
||||||
|
brew_install nng
|
||||||
|
brew_install zstd
|
||||||
|
brew_install soapysdr
|
||||||
|
brew_install hackrf
|
||||||
|
brew_install fftw
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning SatDump v1.2.2..."
|
||||||
|
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/SatDump"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
||||||
|
build_log="$tmp_dir/satdump-build.log"
|
||||||
|
|
||||||
|
# Show periodic progress while building so the user knows it's not hung
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
sleep 30
|
||||||
|
if [ -f "$build_log" ]; then
|
||||||
|
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
|
||||||
|
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
progress_pid=$!
|
||||||
|
|
||||||
|
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >"$build_log" 2>&1 \
|
||||||
|
&& make -j "$(sysctl -n hw.ncpu)" >>"$build_log" 2>&1; then
|
||||||
|
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
make install >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
ok "SatDump installed successfully."
|
||||||
|
else
|
||||||
|
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||||
|
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||||
|
warn "Build log (last 30 lines):"
|
||||||
|
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=15
|
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=19
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -437,12 +820,31 @@ install_macos_packages() {
|
|||||||
progress "Installing direwolf (APRS decoder)"
|
progress "Installing direwolf (APRS decoder)"
|
||||||
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||||
|
|
||||||
|
progress "SSTV decoder"
|
||||||
|
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||||
|
|
||||||
|
progress "Installing DSD (Digital Speech Decoder, optional)"
|
||||||
|
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
||||||
|
echo
|
||||||
|
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
|
||||||
|
if ask_yes_no "Do you want to install DSD?"; then
|
||||||
|
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
|
||||||
|
else
|
||||||
|
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "DSD already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing ffmpeg"
|
progress "Installing ffmpeg"
|
||||||
brew_install ffmpeg
|
brew_install ffmpeg
|
||||||
|
|
||||||
progress "Installing rtl_433"
|
progress "Installing rtl_433"
|
||||||
brew_install rtl_433
|
brew_install rtl_433
|
||||||
|
|
||||||
|
progress "Installing HackRF tools"
|
||||||
|
brew_install hackrf
|
||||||
|
|
||||||
progress "Installing rtlamr (optional)"
|
progress "Installing rtlamr (optional)"
|
||||||
# rtlamr is optional - used for utility meter monitoring
|
# rtlamr is optional - used for utility meter monitoring
|
||||||
if ! cmd_exists rtlamr; then
|
if ! cmd_exists rtlamr; then
|
||||||
@@ -458,18 +860,39 @@ install_macos_packages() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
progress "Installing dump1090"
|
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"
|
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 AIS-catcher"
|
progress "Installing AIS-catcher"
|
||||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
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
|
else
|
||||||
ok "AIS-catcher already installed"
|
ok "AIS-catcher already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
progress "Installing SatDump (optional)"
|
||||||
|
if ! cmd_exists satdump; then
|
||||||
|
echo
|
||||||
|
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||||
|
if ask_yes_no "Do you want to install SatDump?"; then
|
||||||
|
install_satdump_from_source_macos || warn "SatDump build failed. Weather satellite decoding will not be available."
|
||||||
|
else
|
||||||
|
warn "Skipping SatDump installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "SatDump already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing aircrack-ng"
|
progress "Installing aircrack-ng"
|
||||||
brew_install aircrack-ng
|
brew_install aircrack-ng
|
||||||
|
|
||||||
@@ -767,7 +1190,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=20
|
TOTAL_STEPS=26
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -811,19 +1234,9 @@ install_debian_packages() {
|
|||||||
|
|
||||||
progress "RTL-SDR Blog drivers"
|
progress "RTL-SDR Blog drivers"
|
||||||
if cmd_exists rtl_test; then
|
if cmd_exists rtl_test; then
|
||||||
info "RTL-SDR tools already installed."
|
ok "RTL-SDR drivers already installed"
|
||||||
if $IS_DRAGONOS; then
|
|
||||||
info "Skipping RTL-SDR Blog driver installation (DragonOS has working drivers)."
|
|
||||||
else
|
|
||||||
echo "RTL-SDR Blog drivers provide improved support for V4 dongles."
|
|
||||||
echo "Installing these will REPLACE your current RTL-SDR drivers."
|
|
||||||
if ask_yes_no "Install RTL-SDR Blog drivers?"; then
|
|
||||||
install_rtlsdr_blog_drivers_debian
|
|
||||||
else
|
|
||||||
ok "Keeping existing RTL-SDR drivers."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
|
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
||||||
install_rtlsdr_blog_drivers_debian
|
install_rtlsdr_blog_drivers_debian
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -833,12 +1246,31 @@ install_debian_packages() {
|
|||||||
progress "Installing direwolf (APRS decoder)"
|
progress "Installing direwolf (APRS decoder)"
|
||||||
apt_install direwolf || true
|
apt_install direwolf || true
|
||||||
|
|
||||||
|
progress "SSTV decoder"
|
||||||
|
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||||
|
|
||||||
|
progress "Installing DSD (Digital Speech Decoder, optional)"
|
||||||
|
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
||||||
|
echo
|
||||||
|
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
|
||||||
|
if ask_yes_no "Do you want to install DSD?"; then
|
||||||
|
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
|
||||||
|
else
|
||||||
|
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "DSD already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing ffmpeg"
|
progress "Installing ffmpeg"
|
||||||
apt_install ffmpeg
|
apt_install ffmpeg
|
||||||
|
|
||||||
progress "Installing rtl_433"
|
progress "Installing rtl_433"
|
||||||
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
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)"
|
progress "Installing rtlamr (optional)"
|
||||||
# rtlamr is optional - used for utility meter monitoring
|
# rtlamr is optional - used for utility meter monitoring
|
||||||
if ! cmd_exists rtlamr; then
|
if ! cmd_exists rtlamr; then
|
||||||
@@ -918,15 +1350,29 @@ install_debian_packages() {
|
|||||||
ok "AIS-catcher already installed"
|
ok "AIS-catcher already installed"
|
||||||
fi
|
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"
|
progress "Configuring udev rules"
|
||||||
setup_udev_rules_debian
|
setup_udev_rules_debian
|
||||||
|
|
||||||
progress "Kernel driver configuration"
|
progress "Kernel driver configuration"
|
||||||
echo
|
|
||||||
if $IS_DRAGONOS; then
|
if $IS_DRAGONOS; then
|
||||||
info "DragonOS already has RTL-SDR drivers configured correctly."
|
info "DragonOS already has RTL-SDR drivers configured correctly."
|
||||||
info "Skipping kernel driver blacklist (not needed)."
|
elif [[ -f /etc/modprobe.d/blacklist-rtlsdr.conf ]]; then
|
||||||
|
ok "DVB kernel drivers already blacklisted"
|
||||||
else
|
else
|
||||||
|
echo
|
||||||
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
|
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
|
||||||
echo "Blacklisting them allows rtl_sdr tools to access the device."
|
echo "Blacklisting them allows rtl_sdr tools to access the device."
|
||||||
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
|
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
|
||||||
@@ -966,6 +1412,14 @@ final_summary_and_hard_fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -1012,8 +1466,24 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
install_python_deps
|
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
|
final_summary_and_hard_fail
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|
||||||
|
# Clear traps before exiting to prevent spurious errors during cleanup
|
||||||
|
trap - ERR EXIT
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+140
-55
@@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* ADSB Dashboard Styles
|
|
||||||
* Imports design tokens for consistency, with dashboard-specific overrides
|
|
||||||
*/
|
|
||||||
@import url('./core/variables.css');
|
|
||||||
@import url('./core/layout.css');
|
|
||||||
@import url('./core/components.css');
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -13,18 +5,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dashboard-specific aliases (for backward compatibility) */
|
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-dark: var(--bg-primary);
|
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-panel: var(--bg-secondary);
|
--bg-dark: #0b1118;
|
||||||
--bg-card: var(--bg-tertiary);
|
--bg-panel: #101823;
|
||||||
/* Use tokens for shared values, keep custom values for dashboard-specific */
|
--bg-card: #151f2b;
|
||||||
--grid-line: rgba(74, 158, 255, 0.08);
|
--border-color: #263246;
|
||||||
--radar-cyan: var(--accent-cyan);
|
--border-glow: #4aa3ff;
|
||||||
--radar-bg: var(--bg-secondary);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -39,9 +43,10 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-image:
|
background-image:
|
||||||
|
var(--noise-image),
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
background-size: 50px 50px;
|
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -54,10 +59,12 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
color: var(--accent-cyan);
|
||||||
animation: scan 6s linear infinite;
|
animation: scan 6s linear infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
opacity: 0.3;
|
opacity: 0.25;
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan {
|
@keyframes scan {
|
||||||
@@ -70,7 +77,7 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header - Mobile first */
|
/* Header */
|
||||||
.header {
|
.header {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -80,20 +87,31 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
min-height: 52px;
|
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) {
|
@media (min-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -125,14 +143,52 @@ body {
|
|||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -171,15 +227,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid - Mobile first */
|
/* Main dashboard grid - Mobile first */
|
||||||
/* Header ~55px + Stats strip ~55px = ~110px, using 115px for safety */
|
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
|
||||||
.dashboard {
|
.dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100dvh - 115px);
|
height: calc(100dvh - 160px);
|
||||||
height: calc(100vh - 115px); /* Fallback */
|
height: calc(100vh - 160px); /* Fallback */
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +271,7 @@ body {
|
|||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.acars-sidebar {
|
.acars-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: calc(100dvh - 115px);
|
max-height: calc(100dvh - 160px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +679,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-value {
|
.telemetry-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -679,7 +735,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-icao {
|
.aircraft-icao {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
@@ -699,7 +755,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-detail-value {
|
.aircraft-detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -715,14 +771,31 @@ body {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch !important;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
font-size: 11px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-bar label {
|
.controls-bar label {
|
||||||
@@ -789,7 +862,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +873,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,6 +886,24 @@ body {
|
|||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bias-T toggle styling */
|
||||||
|
.bias-t-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: linear-gradient(90deg, rgba(255, 100, 0, 0.15), rgba(255, 100, 0, 0.05));
|
||||||
|
border: 1px solid var(--accent-orange, #ff6400);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent-orange, #ff6400);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bias-t-label input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent-orange, #ff6400);
|
||||||
|
}
|
||||||
|
|
||||||
.control-group.airband-group {
|
.control-group.airband-group {
|
||||||
background: rgba(245, 158, 11, 0.05);
|
background: rgba(245, 158, 11, 0.05);
|
||||||
border-color: rgba(245, 158, 11, 0.2);
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
@@ -860,7 +951,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -892,7 +983,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1095,7 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -1038,7 +1129,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.airband-status {
|
.airband-status {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1198,7 +1289,7 @@ body {
|
|||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-height: calc(100dvh - 115px);
|
min-height: calc(100dvh - 160px);
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1267,12 +1358,6 @@ body {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status bar - compact on mobile */
|
|
||||||
.status-bar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Strip time smaller on mobile */
|
/* Strip time smaller on mobile */
|
||||||
.strip-time {
|
.strip-time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -1388,7 +1473,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-value {
|
.strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1414,7 +1499,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-report-btn {
|
.strip-report-btn {
|
||||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
|
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -1526,7 +1611,7 @@ body {
|
|||||||
|
|
||||||
.report-grid span:nth-child(even) {
|
.report-grid span:nth-child(even) {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-highlights {
|
.report-highlights {
|
||||||
@@ -1717,7 +1802,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-btn.primary {
|
.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;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -1765,7 +1850,7 @@ body {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1919,7 +2004,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.squawk-code {
|
.squawk-code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
+38
-20
@@ -5,38 +5,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-dark: #0a0c10;
|
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-panel: #0f1218;
|
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-card: #141a24;
|
--bg-dark: #0b1118;
|
||||||
--border-color: #1f2937;
|
--bg-panel: #101823;
|
||||||
--border-glow: rgba(74, 158, 255, 0.6);
|
--bg-card: #151f2b;
|
||||||
--text-primary: #e8eaed;
|
--border-color: #263246;
|
||||||
--text-secondary: #9ca3af;
|
--border-glow: rgba(74, 163, 255, 0.4);
|
||||||
--text-dim: #4b5563;
|
--text-primary: #d7e0ee;
|
||||||
--accent-cyan: #4a9eff;
|
--text-secondary: #9fb0c7;
|
||||||
--accent-green: #22c55e;
|
--text-dim: #6f7f94;
|
||||||
--accent-amber: #d4a853;
|
--accent-cyan: #4aa3ff;
|
||||||
--grid-line: rgba(74, 158, 255, 0.08);
|
--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 {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-bg {
|
.radar-bg {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
|
var(--noise-image),
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
background-size: 50px 50px;
|
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -48,10 +52,12 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
color: var(--accent-cyan);
|
||||||
animation: scan 6s linear infinite;
|
animation: scan 6s linear infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
opacity: 0.3;
|
opacity: 0.25;
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan {
|
@keyframes scan {
|
||||||
@@ -72,6 +78,18 @@ body {
|
|||||||
gap: 12px;
|
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 {
|
.logo {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -91,7 +109,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +286,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -306,7 +324,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-meta {
|
.panel-meta {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -347,7 +365,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-row td,
|
.empty-row td,
|
||||||
|
|||||||
+4
-16
@@ -1,21 +1,9 @@
|
|||||||
/*
|
/*
|
||||||
* Agents Management CSS
|
* Agents Management CSS
|
||||||
* Styles for the remote agent management interface
|
* Styles for the remote agent management interface
|
||||||
|
* Inherits CSS variables from core/variables.css
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* 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 in navigation */
|
||||||
.agent-indicator {
|
.agent-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -55,7 +43,7 @@
|
|||||||
.agent-indicator-label {
|
.agent-indicator-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-indicator-count {
|
.agent-indicator-count {
|
||||||
@@ -178,7 +166,7 @@
|
|||||||
.agent-selector-item-url {
|
.agent-selector-item-url {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -203,7 +191,7 @@
|
|||||||
background: rgba(0, 212, 255, 0.1);
|
background: rgba(0, 212, 255, 0.1);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-badge.local,
|
.agent-badge.local,
|
||||||
|
|||||||
+128
-49
@@ -1,10 +1,5 @@
|
|||||||
/**
|
/* AIS Dashboard - Vessel Tracking Interface */
|
||||||
* AIS Dashboard Styles - Vessel Tracking Interface
|
/* Styled to match ADSB Dashboard */
|
||||||
* Imports design tokens for consistency, with dashboard-specific overrides
|
|
||||||
*/
|
|
||||||
@import url('./core/variables.css');
|
|
||||||
@import url('./core/layout.css');
|
|
||||||
@import url('./core/components.css');
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -13,18 +8,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dashboard-specific aliases (for backward compatibility) */
|
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-dark: var(--bg-primary);
|
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-panel: var(--bg-secondary);
|
--bg-dark: #0b1118;
|
||||||
--bg-card: var(--bg-tertiary);
|
--bg-panel: #101823;
|
||||||
/* Use tokens for shared values, keep custom values for dashboard-specific */
|
--bg-card: #151f2b;
|
||||||
--grid-line: rgba(74, 158, 255, 0.08);
|
--border-color: #263246;
|
||||||
--radar-cyan: var(--accent-cyan);
|
--border-glow: #4aa3ff;
|
||||||
--radar-bg: var(--bg-secondary);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -39,9 +46,10 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-image:
|
background-image:
|
||||||
|
var(--noise-image),
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
background-size: 50px 50px;
|
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -54,10 +62,12 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
color: var(--accent-cyan);
|
||||||
animation: scan 6s linear infinite;
|
animation: scan 6s linear infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
opacity: 0.3;
|
opacity: 0.25;
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan {
|
@keyframes scan {
|
||||||
@@ -85,6 +95,18 @@ body {
|
|||||||
min-height: 52px;
|
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) {
|
@media (min-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
@@ -93,7 +115,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -128,10 +150,49 @@ body {
|
|||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
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 {
|
.back-link {
|
||||||
@@ -179,7 +240,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-value {
|
.strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -283,7 +344,7 @@ body {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -310,20 +371,21 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.strip-btn.primary {
|
.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;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid - Mobile first */
|
/* Main dashboard grid - Mobile first */
|
||||||
|
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
|
||||||
.dashboard {
|
.dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100dvh - 95px);
|
height: calc(100dvh - 160px);
|
||||||
height: calc(100vh - 95px);
|
height: calc(100vh - 160px);
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +425,7 @@ body {
|
|||||||
/* Leaflet overrides - Dark map styling */
|
/* Leaflet overrides - Dark map styling */
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Using actual dark tiles now - no filter needed */
|
/* Using actual dark tiles now - no filter needed */
|
||||||
@@ -434,7 +496,7 @@ body {
|
|||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
background: rgba(74, 158, 255, 0.05);
|
background: rgba(74, 158, 255, 0.05);
|
||||||
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -506,7 +568,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-name {
|
.vessel-name {
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -514,7 +576,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-mmsi {
|
.vessel-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
@@ -544,7 +606,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -600,20 +662,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-name {
|
.vessel-item-name {
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-type {
|
.vessel-item-type {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-speed {
|
.vessel-item-speed {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -624,14 +686,31 @@ body {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch !important;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
font-size: 11px;
|
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 {
|
.control-group {
|
||||||
@@ -683,7 +762,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,7 +773,7 @@ body {
|
|||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +792,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
background: var(--accent-green);
|
background: var(--accent-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -830,7 +909,7 @@ body {
|
|||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-height: calc(100dvh - 95px);
|
min-height: calc(100dvh - 160px);
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1000,7 +1079,7 @@ body {
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,7 +1154,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-category {
|
.dsc-message-category {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1092,13 +1171,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-time {
|
.dsc-message-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-mmsi {
|
.dsc-message-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1195,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-message-pos {
|
.dsc-message-pos {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1223,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-header {
|
.dsc-distress-alert .dsc-alert-header {
|
||||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
@@ -1153,7 +1232,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-mmsi {
|
.dsc-distress-alert .dsc-alert-mmsi {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1173,7 +1252,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-position {
|
.dsc-distress-alert .dsc-alert-position {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -1184,7 +1263,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
background: var(--timeline-bg);
|
background: var(--timeline-bg);
|
||||||
border: 1px solid var(--timeline-border);
|
border: 1px solid var(--timeline-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +497,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-timeline-tooltip-header {
|
.activity-timeline-tooltip-header {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-name {
|
.device-name {
|
||||||
font-family: 'Inter', -apple-system, sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
PROTOCOL BADGES
|
PROTOCOL BADGES
|
||||||
============================================ */
|
============================================ */
|
||||||
.signal-proto-badge.device-protocol {
|
.signal-proto-badge.device-protocol {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
.device-heuristic-badge {
|
.device-heuristic-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rssi-current {
|
.rssi-current {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -186,13 +186,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rssi-value {
|
.rssi-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rssi-current-value {
|
.rssi-current-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-range-band .range-label {
|
.device-range-band .range-label {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--range-color);
|
color: var(--range-color);
|
||||||
@@ -230,13 +230,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-range-band .range-estimate {
|
.device-range-band .range-estimate {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-range-band .range-confidence {
|
.device-range-band .range-confidence {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-manufacturer .mfr-name {
|
.device-manufacturer .mfr-name {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-seen-count .seen-icon {
|
.device-seen-count .seen-icon {
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-timestamp {
|
.device-timestamp {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.device-uuid {
|
.device-uuid {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: var(--bg-tertiary, #1a1a1a);
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heuristic-item .heuristic-status {
|
.heuristic-item .heuristic-status {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -426,7 +426,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-card-title {
|
.message-card-title {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-card-details {
|
.message-card-details {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -476,7 +476,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-action-btn {
|
.message-action-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-modal-subtitle {
|
.signal-details-modal-subtitle {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
}
|
}
|
||||||
@@ -635,7 +635,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-copy-addr-btn {
|
.signal-details-copy-addr-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--bg-secondary, #252525);
|
background: var(--bg-secondary, #252525);
|
||||||
@@ -677,7 +677,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-section-title {
|
.modal-section-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -698,7 +698,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-rssi-large {
|
.modal-rssi-large {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -744,7 +744,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-signal-stats .stat-value {
|
.modal-signal-stats .stat-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -778,7 +778,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-info-grid .info-value.mono {
|
.modal-info-grid .info-value.mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +790,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-uuid {
|
.modal-uuid {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--bg-secondary, #1a1a1a);
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
@@ -822,7 +822,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heuristic-indicator {
|
.heuristic-indicator {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
min-width: max-content;
|
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 */
|
/* Stats */
|
||||||
.function-strip .strip-stat {
|
.function-strip .strip-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -37,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.function-strip .strip-value {
|
.function-strip .strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -250,7 +261,7 @@
|
|||||||
|
|
||||||
/* Time display */
|
/* Time display */
|
||||||
.function-strip .strip-time {
|
.function-strip .strip-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|||||||
@@ -14,10 +14,18 @@
|
|||||||
|
|
||||||
.radar-device {
|
.radar-device {
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
transform-origin: center center;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-device:hover {
|
.radar-device:hover {
|
||||||
transform: scale(1.3);
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invisible larger hit area to prevent hover flicker */
|
||||||
|
.radar-device-hitarea {
|
||||||
|
fill: transparent;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-dot-pulse circle:first-child {
|
.radar-dot-pulse circle:first-child {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-feed-title {
|
.signal-feed-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); }
|
.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); }
|
||||||
|
|
||||||
.signal-filter-count {
|
.signal-filter-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
|
|
||||||
/* Protocol badge */
|
/* Protocol badge */
|
||||||
.signal-proto-badge {
|
.signal-proto-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
|
|
||||||
/* Frequency/Address badge */
|
/* Frequency/Address badge */
|
||||||
.signal-freq-badge {
|
.signal-freq-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-sender {
|
.signal-sender {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
}
|
}
|
||||||
@@ -392,7 +392,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-timestamp {
|
.signal-timestamp {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -400,7 +400,7 @@
|
|||||||
|
|
||||||
/* Message content preview */
|
/* Message content preview */
|
||||||
.signal-message {
|
.signal-message {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -638,13 +638,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-advanced-value {
|
.signal-advanced-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-raw-data {
|
.signal-raw-data {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -716,7 +716,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -864,7 +864,7 @@
|
|||||||
.signal-search-input {
|
.signal-search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -887,7 +887,7 @@
|
|||||||
SEEN COUNT BADGE
|
SEEN COUNT BADGE
|
||||||
============================================ */
|
============================================ */
|
||||||
.signal-seen-count {
|
.signal-seen-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -923,7 +923,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-sensor-reading .sensor-value {
|
.signal-sensor-reading .sensor-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -967,7 +967,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-meter-reading .meter-value {
|
.signal-meter-reading .meter-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1020,6 +1020,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-width: 0; /* Allow column to shrink in grid */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meter-aggregated-label {
|
.meter-aggregated-label {
|
||||||
@@ -1030,10 +1032,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meter-aggregated-value {
|
.meter-aggregated-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Consumption column */
|
/* Consumption column */
|
||||||
@@ -1044,7 +1049,7 @@
|
|||||||
|
|
||||||
/* Delta badge */
|
/* Delta badge */
|
||||||
.meter-delta {
|
.meter-delta {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -1068,20 +1073,25 @@
|
|||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meter-sparkline-placeholder {
|
.meter-sparkline-placeholder {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rate display */
|
/* Rate display */
|
||||||
.meter-rate-value {
|
.meter-rate-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-cyan, #4a9eff);
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update animation */
|
/* Update animation */
|
||||||
@@ -1151,7 +1161,7 @@
|
|||||||
DISTANCE DISPLAY
|
DISTANCE DISPLAY
|
||||||
============================================ */
|
============================================ */
|
||||||
.signal-distance {
|
.signal-distance {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1298,7 +1308,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-strength-indicator.no-data {
|
.signal-strength-indicator.no-data {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -1314,7 +1324,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-strength-label {
|
.signal-strength-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1394,7 +1404,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1510,7 +1520,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-raw-modal-title {
|
.station-raw-modal-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -1546,7 +1556,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-raw-data-display {
|
.station-raw-data-display {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--accent-green, #00ff88);
|
color: var(--accent-green, #00ff88);
|
||||||
@@ -1571,7 +1581,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station-raw-copy-btn {
|
.station-raw-copy-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--accent-purple, #8a2be2);
|
background: var(--accent-purple, #8a2be2);
|
||||||
@@ -1741,7 +1751,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-modal-title {
|
.signal-details-modal-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -1778,7 +1788,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-copy-btn {
|
.signal-details-copy-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--accent-purple, #8a2be2);
|
background: var(--accent-purple, #8a2be2);
|
||||||
@@ -1804,7 +1814,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-title {
|
.signal-details-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1816,7 +1826,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-message {
|
.signal-details-message {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
background: var(--bg-secondary, #252525);
|
background: var(--bg-secondary, #252525);
|
||||||
@@ -1847,14 +1857,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signal-details-value {
|
.signal-details-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Raw data in modal */
|
/* Raw data in modal */
|
||||||
.signal-details-modal .signal-raw-data {
|
.signal-details-modal .signal-raw-data {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary, #aaa);
|
color: var(--text-secondary, #aaa);
|
||||||
background: var(--bg-tertiary, #1a1a1a);
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
background: var(--bg-card, #1a1a1a);
|
background: var(--bg-card, #1a1a1a);
|
||||||
border: 1px solid var(--border-color, #333);
|
border: 1px solid var(--border-color, #333);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-timeline.collapsed .signal-timeline-body {
|
.signal-timeline.collapsed .signal-timeline-body {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-version-value {
|
.update-version-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-release-notes code {
|
.update-release-notes code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg-tertiary, #151a23);
|
background: var(--bg-tertiary, #151a23);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-result-text code {
|
.update-result-text code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ body {
|
|||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
|
background-image:
|
||||||
|
var(--noise-image),
|
||||||
|
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
|
||||||
|
linear-gradient(180deg, var(--grid-dot), transparent 35%),
|
||||||
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
|
||||||
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -39,6 +47,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
line-height: var(--leading-tight);
|
line-height: var(--leading-tight);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: var(--text-4xl); }
|
h1 { font-size: var(--text-4xl); }
|
||||||
@@ -81,13 +90,15 @@ code, kbd, pre, samp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -125,7 +136,7 @@ button:disabled {
|
|||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
@@ -138,7 +149,7 @@ select:focus,
|
|||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: 0 0 0 3px var(--accent-cyan-dim);
|
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
@@ -149,7 +160,7 @@ textarea::placeholder {
|
|||||||
select {
|
select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
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='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
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-repeat: no-repeat;
|
||||||
background-position: right 8px center;
|
background-position: right 8px center;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
@@ -191,14 +202,14 @@ td {
|
|||||||
th {
|
th {
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-tertiary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover td {
|
tr:hover td {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
+144
-25
@@ -23,6 +23,9 @@
|
|||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:focus-visible {
|
.btn:focus-visible {
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
color: var(--text-inverse);
|
color: var(--text-inverse);
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
@@ -48,24 +52,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-tertiary);
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-color: transparent;
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-elevated);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +123,11 @@
|
|||||||
CARDS / PANELS
|
CARDS / PANELS
|
||||||
============================================ */
|
============================================ */
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card);
|
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -132,6 +137,7 @@
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header-title {
|
.card-header-title {
|
||||||
@@ -154,10 +160,29 @@
|
|||||||
|
|
||||||
/* Panel variant (used in dashboards) */
|
/* Panel variant (used in dashboards) */
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--bg-card);
|
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
.panel-header {
|
||||||
@@ -172,6 +197,19 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--text-secondary);
|
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 {
|
.panel-indicator {
|
||||||
@@ -203,6 +241,7 @@
|
|||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
@@ -225,6 +264,49 @@
|
|||||||
color: var(--accent-red);
|
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 INDICATORS
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -239,12 +321,12 @@
|
|||||||
.status-dot.online,
|
.status-dot.online,
|
||||||
.status-dot.active {
|
.status-dot.active {
|
||||||
background: var(--status-online);
|
background: var(--status-online);
|
||||||
box-shadow: 0 0 6px var(--status-online);
|
box-shadow: 0 0 4px var(--status-online);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.warning {
|
.status-dot.warning {
|
||||||
background: var(--status-warning);
|
background: var(--status-warning);
|
||||||
box-shadow: 0 0 6px var(--status-warning);
|
box-shadow: 0 0 4px var(--status-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.error,
|
.status-dot.error,
|
||||||
@@ -571,6 +653,21 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--text-secondary);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -578,14 +675,26 @@
|
|||||||
============================================ */
|
============================================ */
|
||||||
.divider {
|
.divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-color);
|
background-image: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--border-light),
|
||||||
|
var(--border-light) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 12px
|
||||||
|
);
|
||||||
margin: var(--space-4) 0;
|
margin: var(--space-4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider-vertical {
|
.divider-vertical {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--border-color);
|
background-image: repeating-linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--border-light),
|
||||||
|
var(--border-light) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 12px
|
||||||
|
);
|
||||||
margin: 0 var(--space-3);
|
margin: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,13 +704,11 @@
|
|||||||
|
|
||||||
/* Button hover lift */
|
/* Button hover lift */
|
||||||
.btn:hover:not(:disabled) {
|
.btn:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 0 0 1px var(--border-light);
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active:not(:disabled) {
|
.btn:active:not(:disabled) {
|
||||||
transform: translateY(0);
|
box-shadow: inset 0 0 0 1px var(--border-light);
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card/Panel hover effects */
|
/* Card/Panel hover effects */
|
||||||
@@ -637,10 +744,10 @@
|
|||||||
|
|
||||||
@keyframes statusGlow {
|
@keyframes statusGlow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 6px var(--status-online);
|
box-shadow: 0 0 4px var(--status-online);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 12px var(--status-online), 0 0 20px var(--status-online);
|
box-shadow: 0 0 8px var(--status-online), 0 0 12px var(--status-online);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +757,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge:hover {
|
.badge:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Alert entrance animation */
|
/* Alert entrance animation */
|
||||||
@@ -684,21 +791,33 @@ input:focus,
|
|||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: 0 0 0 3px var(--accent-cyan-dim), 0 0 20px rgba(74, 158, 255, 0.1);
|
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav item active indicator */
|
/* Nav item active indicator */
|
||||||
|
.nav-item,
|
||||||
|
.mode-nav-btn,
|
||||||
|
.mobile-nav-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::after,
|
||||||
.mode-nav-btn.active::after,
|
.mode-nav-btn.active::after,
|
||||||
.mobile-nav-btn.active::after {
|
.mobile-nav-btn.active::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -2px;
|
left: 12%;
|
||||||
left: 50%;
|
right: 12%;
|
||||||
transform: translateX(-50%);
|
bottom: 2px;
|
||||||
width: 60%;
|
height: 1px;
|
||||||
height: 2px;
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
background: currentColor;
|
opacity: 0.75;
|
||||||
border-radius: var(--radius-full);
|
animation: railPulse 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes railPulse {
|
||||||
|
0%, 100% { opacity: 0.45; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth tooltip appearance */
|
/* Smooth tooltip appearance */
|
||||||
|
|||||||
@@ -28,11 +28,24 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: var(--z-sticky);
|
z-index: var(--z-sticky);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-left {
|
.app-header-left {
|
||||||
@@ -119,12 +132,25 @@
|
|||||||
.app-nav {
|
.app-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
height: var(--nav-height);
|
height: var(--nav-height);
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav::-webkit-scrollbar {
|
.app-nav::-webkit-scrollbar {
|
||||||
@@ -181,7 +207,7 @@
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
@@ -275,11 +301,24 @@
|
|||||||
============================================ */
|
============================================ */
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: none;
|
display: none;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav::-webkit-scrollbar {
|
.mobile-nav::-webkit-scrollbar {
|
||||||
@@ -359,7 +398,7 @@
|
|||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -413,9 +452,22 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header-logo {
|
.dashboard-header-logo {
|
||||||
@@ -445,7 +497,7 @@
|
|||||||
|
|
||||||
.dashboard-sidebar {
|
.dashboard-sidebar {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -589,13 +641,25 @@
|
|||||||
/* Mode Navigation Bar */
|
/* Mode Navigation Bar */
|
||||||
.mode-nav {
|
.mode-nav {
|
||||||
display: none;
|
display: none;
|
||||||
background: #151a23 !important; /* Explicit color - forced to ensure consistency */
|
background: var(--bg-secondary) !important; /* Explicit color - forced to ensure consistency */
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-nav::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.mode-nav {
|
.mode-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -10,51 +10,56 @@
|
|||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
/* Backgrounds - layered depth system */
|
/* Backgrounds - layered depth system */
|
||||||
--bg-primary: #0a0c10;
|
--bg-primary: #0b1118;
|
||||||
--bg-secondary: #0f1218;
|
--bg-secondary: #101823;
|
||||||
--bg-tertiary: #151a23;
|
--bg-tertiary: #151f2b;
|
||||||
--bg-card: #121620;
|
--bg-card: #121a25;
|
||||||
--bg-elevated: #1a202c;
|
--bg-elevated: #1b2734;
|
||||||
--bg-overlay: rgba(0, 0, 0, 0.7);
|
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||||
|
|
||||||
/* Background aliases for components */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
--bg-panel: var(--bg-secondary);
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
/* Accent colors */
|
/* Accent colors */
|
||||||
--accent-cyan: #4a9eff;
|
--accent-cyan: #4aa3ff;
|
||||||
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
|
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||||
--accent-cyan-hover: #6bb3ff;
|
--accent-cyan-hover: #6bb3ff;
|
||||||
--accent-green: #22c55e;
|
--accent-green: #38c180;
|
||||||
--accent-green-dim: rgba(34, 197, 94, 0.15);
|
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||||
--accent-red: #ef4444;
|
--accent-red: #e25d5d;
|
||||||
--accent-red-dim: rgba(239, 68, 68, 0.15);
|
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||||
--accent-orange: #f59e0b;
|
--accent-orange: #d6a85e;
|
||||||
--accent-orange-dim: rgba(245, 158, 11, 0.15);
|
--accent-orange-dim: rgba(214, 168, 94, 0.16);
|
||||||
--accent-amber: #d4a853;
|
--accent-amber: #d6a85e;
|
||||||
--accent-amber-dim: rgba(212, 168, 83, 0.15);
|
--accent-amber-dim: rgba(214, 168, 94, 0.18);
|
||||||
--accent-yellow: #eab308;
|
--accent-yellow: #e1c26b;
|
||||||
--accent-purple: #a855f7;
|
--accent-purple: #8f7bd6;
|
||||||
|
|
||||||
/* Text hierarchy */
|
/* Text hierarchy */
|
||||||
--text-primary: #e8eaed;
|
--text-primary: #d7e0ee;
|
||||||
--text-secondary: #9ca3af;
|
--text-secondary: #9fb0c7;
|
||||||
--text-dim: #4b5563;
|
--text-dim: #6f7f94;
|
||||||
--text-muted: #374151;
|
--text-muted: #445266;
|
||||||
--text-inverse: #0a0c10;
|
--text-inverse: #0b1118;
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--border-color: #1f2937;
|
--border-color: #263246;
|
||||||
--border-light: #374151;
|
--border-light: #354458;
|
||||||
--border-glow: rgba(74, 158, 255, 0.2);
|
--border-glow: rgba(74, 163, 255, 0.25);
|
||||||
--border-focus: var(--accent-cyan);
|
--border-focus: var(--accent-cyan);
|
||||||
|
|
||||||
/* Status colors */
|
/* Status colors */
|
||||||
--status-online: #22c55e;
|
--status-online: #38c180;
|
||||||
--status-warning: #f59e0b;
|
--status-warning: #d6a85e;
|
||||||
--status-error: #ef4444;
|
--status-error: #e25d5d;
|
||||||
--status-offline: #6b7280;
|
--status-offline: #6f7f94;
|
||||||
--status-info: #3b82f6;
|
--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
|
SPACING SCALE
|
||||||
@@ -73,8 +78,8 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
TYPOGRAPHY
|
TYPOGRAPHY
|
||||||
============================================ */
|
============================================ */
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
|
|
||||||
/* Font sizes */
|
/* Font sizes */
|
||||||
--text-xs: 10px;
|
--text-xs: 10px;
|
||||||
@@ -100,19 +105,19 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
BORDERS & RADIUS
|
BORDERS & RADIUS
|
||||||
============================================ */
|
============================================ */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 3px;
|
||||||
--radius-md: 6px;
|
--radius-md: 4px;
|
||||||
--radius-lg: 8px;
|
--radius-lg: 6px;
|
||||||
--radius-xl: 12px;
|
--radius-xl: 8px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SHADOWS
|
SHADOWS
|
||||||
============================================ */
|
============================================ */
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
|
||||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
|
||||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
--shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
|
||||||
--shadow-glow: 0 0 20px rgba(74, 158, 255, 0.15);
|
--shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TRANSITIONS
|
TRANSITIONS
|
||||||
@@ -147,43 +152,47 @@
|
|||||||
LIGHT THEME OVERRIDES
|
LIGHT THEME OVERRIDES
|
||||||
============================================ */
|
============================================ */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #f8fafc;
|
--bg-primary: #f4f7fb;
|
||||||
--bg-secondary: #f1f5f9;
|
--bg-secondary: #e9eef5;
|
||||||
--bg-tertiary: #e2e8f0;
|
--bg-tertiary: #dde5f0;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-elevated: #f8fafc;
|
--bg-elevated: #f1f4f9;
|
||||||
--bg-overlay: rgba(255, 255, 255, 0.9);
|
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||||
|
|
||||||
/* Background aliases for components */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
--bg-panel: var(--bg-secondary);
|
--bg-panel: var(--bg-secondary);
|
||||||
|
|
||||||
--accent-cyan: #2563eb;
|
--accent-cyan: #1f5fa8;
|
||||||
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
|
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||||
--accent-cyan-hover: #1d4ed8;
|
--accent-cyan-hover: #2c73bf;
|
||||||
--accent-green: #16a34a;
|
--accent-green: #1f8a57;
|
||||||
--accent-green-dim: rgba(22, 163, 74, 0.1);
|
--accent-green-dim: rgba(31, 138, 87, 0.12);
|
||||||
--accent-red: #dc2626;
|
--accent-red: #c74444;
|
||||||
--accent-red-dim: rgba(220, 38, 38, 0.1);
|
--accent-red-dim: rgba(199, 68, 68, 0.12);
|
||||||
--accent-orange: #d97706;
|
--accent-orange: #b5863a;
|
||||||
--accent-orange-dim: rgba(217, 119, 6, 0.1);
|
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||||
--accent-amber: #b45309;
|
--accent-amber: #b5863a;
|
||||||
--accent-amber-dim: rgba(180, 83, 9, 0.1);
|
--accent-amber-dim: rgba(181, 134, 58, 0.12);
|
||||||
|
|
||||||
--text-primary: #0f172a;
|
--text-primary: #122034;
|
||||||
--text-secondary: #475569;
|
--text-secondary: #3a4a5f;
|
||||||
--text-dim: #94a3b8;
|
--text-dim: #6b7c93;
|
||||||
--text-muted: #cbd5e1;
|
--text-muted: #aab6c8;
|
||||||
--text-inverse: #f8fafc;
|
--text-inverse: #f4f7fb;
|
||||||
|
|
||||||
--border-color: #e2e8f0;
|
--border-color: #d1d9e6;
|
||||||
--border-light: #cbd5e1;
|
--border-light: #c1ccdb;
|
||||||
--border-glow: rgba(37, 99, 235, 0.15);
|
--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-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||||
--shadow-glow: 0 0 20px rgba(37, 99, 235, 0.1);
|
--shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@@ -1,67 +1,18 @@
|
|||||||
/* Local font declarations for offline mode */
|
/* Local font declarations for offline mode */
|
||||||
|
|
||||||
/* Inter - Primary UI font */
|
/* Space Mono - Console font */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Space Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
|
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Space Mono';
|
||||||
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-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
|
src: url('/static/vendor/fonts/SpaceMono-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');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 100;
|
||||||
|
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, 'JetBrains Mono', monospace);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content h2 {
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content h3 {
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
margin: 25px 0 15px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #4b5563);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-close:hover {
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary, #0a0c10);
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .desc {
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
position: relative;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-size: 13px;
|
||||||
|
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-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary, #0a0c10);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
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: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
+549
-193
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
.landing-title {
|
.landing-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.4em;
|
letter-spacing: 0.4em;
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.landing-tagline {
|
.landing-tagline {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
/* Hacker Style Error */
|
/* Hacker Style Error */
|
||||||
.flash-error {
|
.flash-error {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box; /* Crucial for visibility */
|
box-sizing: border-box; /* Crucial for visibility */
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
border: 2px solid var(--accent-cyan);
|
border: 2px solid var(--accent-cyan);
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
.landing-version {
|
.landing-version {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
border-color: rgba(74, 158, 255, 0.3);
|
border-color: rgba(74, 158, 255, 0.3);
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-value {
|
.aprs-strip .strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
|
|
||||||
/* Time display */
|
/* Time display */
|
||||||
.aprs-strip .strip-time {
|
.aprs-strip .strip-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -326,3 +326,50 @@
|
|||||||
.aprs-meter-status.no-signal {
|
.aprs-meter-status.no-signal {
|
||||||
color: var(--accent-yellow);
|
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,430 @@
|
|||||||
|
/* BT Locate Mode Styles */
|
||||||
|
|
||||||
|
/* Environment preset grid */
|
||||||
|
.btl-env-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-btn.active {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
border-color: var(--accent-green, #00ff88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-env-n {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PROXIMITY HUD — main visuals area
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.btl-hud {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
min-width: 130px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band.immediate {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
|
||||||
|
animation: btl-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band.near {
|
||||||
|
color: #f97316;
|
||||||
|
border-color: #f97316;
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
box-shadow: 0 0 15px rgba(249, 115, 22, 0.15);
|
||||||
|
animation: btl-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band.far {
|
||||||
|
color: #eab308;
|
||||||
|
border-color: #eab308;
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
box-shadow: 0 0 10px rgba(234, 179, 8, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btl-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metric-lg .btl-hud-value {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-unit {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-audio-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-audio-toggle input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-clear-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-clear-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom info bar */
|
||||||
|
.btl-hud-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-info-item {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-info-sep {
|
||||||
|
color: rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-diag {
|
||||||
|
display: none;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
opacity: 0.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-diag:not(:empty) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VISUALS AREA — map + chart
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.btl-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 250px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btLocateMap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-rssi-chart-container {
|
||||||
|
height: 100px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-rssi-chart-container .btl-chart-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btLocateRssiChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LOCATE BUTTON — Bluetooth device cards
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.bt-locate-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--accent-green, #00ff88);
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-locate-btn:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
border-color: var(--accent-green, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-locate-btn svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
IRK DETECT BUTTON + DEVICE PICKER
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.btl-detect-irk-btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-detect-irk-btn:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-detect-irk-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker {
|
||||||
|
margin-top: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-status {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-list {
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-item {
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-item:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-item:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-irk-picker-meta {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE — stack HUD vertically on narrow
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.btl-hud {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-band {
|
||||||
|
min-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-metrics {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-controls {
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/* 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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gpsSkyCanvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position info panel */
|
||||||
|
.gps-position-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-position-panel h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-big {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal Strength Bars */
|
||||||
|
.gps-signal-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-panel h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
height: 140px;
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar {
|
||||||
|
width: 14px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-bar.unused {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-prn {
|
||||||
|
font-size: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-signal-snr {
|
||||||
|
font-size: 7px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Constellation colors */
|
||||||
|
.gps-const-gps { background-color: #00d4ff; }
|
||||||
|
.gps-const-glonass { background-color: #00ff88; }
|
||||||
|
.gps-const-galileo { background-color: #ff8800; }
|
||||||
|
.gps-const-beidou { background-color: #ff4466; }
|
||||||
|
.gps-const-sbas { background-color: #ffdd00; }
|
||||||
|
.gps-const-qzss { background-color: #cc66ff; }
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.gps-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.gps-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-empty-state svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-empty-state p {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 300px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gps-visuals-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-skyview-panel,
|
||||||
|
.gps-position-panel {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-pos-big {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-sidebar-toggle-text {
|
.mesh-sidebar-toggle-text {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -242,14 +242,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-status-text {
|
.mesh-strip-status-text {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-select {
|
.mesh-strip-select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -259,8 +259,29 @@
|
|||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mesh-strip-input {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-strip-input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesh-strip-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
.mesh-strip-btn {
|
.mesh-strip-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -304,7 +325,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-value {
|
.mesh-strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -328,7 +349,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-strip-label {
|
.mesh-strip-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -387,7 +408,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -400,7 +421,7 @@
|
|||||||
.mesh-map-stats {
|
.mesh-map-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -438,7 +459,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-map .leaflet-popup-content {
|
.mesh-map .leaflet-popup-content {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin: 10px 12px;
|
margin: 10px 12px;
|
||||||
}
|
}
|
||||||
@@ -458,7 +479,7 @@
|
|||||||
0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
|
0 0 20px 8px rgba(0, 255, 255, 0.7), /* Strong outer glow */
|
||||||
inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
|
inset 0 0 8px rgba(255, 255, 255, 0.3); /* Inner highlight */
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
|
text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
|
||||||
@@ -552,7 +573,7 @@
|
|||||||
|
|
||||||
.mesh-node-value {
|
.mesh-node-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-node-id {
|
.mesh-node-id {
|
||||||
@@ -591,7 +612,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-channel-index {
|
.mesh-channel-index {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -602,7 +623,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-channel-name {
|
.mesh-channel-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -619,7 +640,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-channel-badge {
|
.mesh-channel-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -694,7 +715,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-messages-title {
|
.mesh-messages-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -714,7 +735,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-messages-filter select {
|
.mesh-messages-filter select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -779,7 +800,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,7 +829,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-channel {
|
.mesh-message-channel {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -817,7 +838,7 @@
|
|||||||
|
|
||||||
.mesh-message-time {
|
.mesh-message-time {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-body {
|
.mesh-message-body {
|
||||||
@@ -828,7 +849,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-body.app-type {
|
.mesh-message-body.app-type {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -849,7 +870,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,7 +921,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-message-status {
|
.mesh-message-status {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -965,7 +986,7 @@
|
|||||||
|
|
||||||
#meshChannelModal input[type="text"],
|
#meshChannelModal input[type="text"],
|
||||||
#meshChannelModal select {
|
#meshChannelModal select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -999,7 +1020,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-compose-channel {
|
.mesh-compose-channel {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -1016,7 +1037,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-compose-to {
|
.mesh-compose-to {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -1043,7 +1064,7 @@
|
|||||||
|
|
||||||
.mesh-compose-input {
|
.mesh-compose-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
@@ -1090,7 +1111,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-compose-hint {
|
.mesh-compose-hint {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -1211,7 +1232,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1268,7 +1289,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-label {
|
.mesh-traceroute-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1300,7 +1321,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-hop-node {
|
.mesh-traceroute-hop-node {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1308,14 +1329,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-hop-id {
|
.mesh-traceroute-hop-id {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-traceroute-snr {
|
.mesh-traceroute-snr {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -1350,7 +1371,7 @@
|
|||||||
|
|
||||||
.mesh-traceroute-timestamp {
|
.mesh-traceroute-timestamp {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -1381,7 +1402,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1405,7 +1426,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1438,7 +1459,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -1473,7 +1494,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-chart-label {
|
.mesh-chart-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
fill: var(--text-dim);
|
fill: var(--text-dim);
|
||||||
}
|
}
|
||||||
@@ -1504,7 +1525,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-network-node-id {
|
.mesh-network-node-id {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1532,13 +1553,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mesh-network-neighbor-id {
|
.mesh-network-neighbor-id {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesh-network-neighbor-snr {
|
.mesh-network-neighbor-snr {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -1571,7 +1592,7 @@
|
|||||||
.mesh-badge {
|
.mesh-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-stations-title {
|
.spy-stations-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-station-name {
|
.spy-station-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
|
|
||||||
/* Type Badge */
|
/* Type Badge */
|
||||||
.spy-station-badge {
|
.spy-station-badge {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-meta-mode {
|
.spy-meta-mode {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-list {
|
.spy-freq-list {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-item {
|
.spy-freq-item {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spy-freq-select {
|
.spy-freq-select {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -340,7 +340,9 @@
|
|||||||
MODE VISIBILITY - Ensure sidebar shows when active
|
MODE VISIBILITY - Ensure sidebar shows when active
|
||||||
============================================ */
|
============================================ */
|
||||||
#spystationsMode.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; }
|
||||||
|
}
|
||||||
+216
-23
@@ -7,7 +7,9 @@
|
|||||||
MODE VISIBILITY
|
MODE VISIBILITY
|
||||||
============================================ */
|
============================================ */
|
||||||
#sstvMode.active {
|
#sstvMode.active {
|
||||||
display: block !important;
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -86,14 +88,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-status-text {
|
.sstv-strip-status-text {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-btn {
|
.sstv-strip-btn {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -137,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-value {
|
.sstv-strip-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -148,7 +150,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-strip-label {
|
.sstv-strip-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -165,7 +167,7 @@
|
|||||||
.sstv-loc-input {
|
.sstv-loc-input {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -236,7 +238,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -278,7 +280,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-mode-label {
|
.sstv-mode-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -300,7 +302,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-status-message {
|
.sstv-status-message {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -362,14 +364,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-gallery-count {
|
.sstv-gallery-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -388,12 +390,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-card {
|
.sstv-image-card {
|
||||||
|
position: relative;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-card:hover {
|
.sstv-image-card:hover {
|
||||||
@@ -402,6 +404,10 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-image-card-inner {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-image-preview {
|
.sstv-image-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
@@ -410,13 +416,55 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-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-image-card:hover .sstv-image-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-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-image-actions button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-image-actions button:last-child:hover {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-image-info {
|
.sstv-image-info {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-mode {
|
.sstv-image-mode {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -424,7 +472,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-image-timestamp {
|
.sstv-image-timestamp {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
@@ -500,7 +548,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-map-label {
|
.sstv-map-label {
|
||||||
@@ -526,7 +574,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-pass-label {
|
.sstv-pass-label {
|
||||||
@@ -561,7 +609,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-iss-label {
|
.sstv-iss-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffcc00;
|
color: #ffcc00;
|
||||||
@@ -607,7 +655,7 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -635,7 +683,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-countdown-value {
|
.sstv-countdown-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -661,7 +709,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-countdown-label {
|
.sstv-countdown-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -686,7 +734,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-detail-label {
|
.sstv-detail-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -694,7 +742,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sstv-detail-value {
|
.sstv-detail-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -708,7 +756,7 @@
|
|||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -736,6 +784,96 @@
|
|||||||
animation: pulse 0.5s infinite;
|
animation: pulse 0.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SIGNAL MONITOR
|
||||||
|
============================================ */
|
||||||
|
.sstv-signal-monitor {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-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-signal-monitor-header svg {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-level-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-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-signal-bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-signal-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-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-signal-status-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-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-signal-vis-state.active {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
IMAGE MODAL
|
IMAGE MODAL
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -764,6 +902,40 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sstv-modal-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 60px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-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-modal-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sstv-modal-btn.delete:hover {
|
||||||
|
background: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
.sstv-modal-close {
|
.sstv-modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
@@ -775,12 +947,33 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sstv-modal-close:hover {
|
.sstv-modal-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clear All button */
|
||||||
|
.sstv-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-gallery-clear-btn:hover {
|
||||||
|
color: var(--accent-red, #ff3366);
|
||||||
|
border-color: var(--accent-red, #ff3366);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+240
-15
@@ -32,17 +32,12 @@
|
|||||||
.threat-card.low.active { background: rgba(0,255,136,0.2); }
|
.threat-card.low.active { background: rgba(0,255,136,0.2); }
|
||||||
|
|
||||||
/* TSCM Dashboard */
|
/* TSCM Dashboard */
|
||||||
/* Ensure output-panel doesn't clip TSCM content */
|
|
||||||
.output-panel:has(.tscm-dashboard) {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tscm-dashboard {
|
.tscm-dashboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
overflow: visible;
|
overflow-y: auto;
|
||||||
padding: 20px 16px 80px 16px; /* Extra top padding for function strip visibility */
|
padding-bottom: 80px; /* Space for status bar */
|
||||||
}
|
}
|
||||||
.tscm-threat-banner {
|
.tscm-threat-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -50,6 +45,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.tscm-threat-banner .threat-card {
|
.tscm-threat-banner .threat-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -73,11 +69,6 @@
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
/* Full-width panels (like Detected Threats) get more height */
|
|
||||||
.tscm-panel[style*="grid-column: span 2"] {
|
|
||||||
min-height: 150px;
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
.tscm-panel-header {
|
.tscm-panel-header {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
@@ -172,7 +163,7 @@
|
|||||||
}
|
}
|
||||||
.tscm-privilege-warning .warning-action {
|
.tscm-privilege-warning .warning-action {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
@@ -205,6 +196,39 @@
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
.tracker-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 51, 102, 0.2);
|
||||||
|
color: #ff3366;
|
||||||
|
border: 1px solid rgba(255, 51, 102, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.client-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
color: #4a9eff;
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.known-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
color: #00ff88;
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
.tscm-device-header {
|
.tscm-device-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -470,6 +494,18 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
.device-detail-id {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.tscm-more-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.indicator-list {
|
.indicator-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -887,6 +923,42 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.tscm-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.tscm-filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tscm-filter-group label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
.tscm-filter-group select {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.tscm-filter-status {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Advanced Modal Styles */
|
/* Advanced Modal Styles */
|
||||||
.tscm-advanced-modal {
|
.tscm-advanced-modal {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
@@ -977,7 +1049,7 @@
|
|||||||
.known-device-id {
|
.known-device-id {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.known-device-actions {
|
.known-device-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1216,7 +1288,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.cap-can-list, .cap-cannot-list {
|
.cap-can-list, .cap-cannot-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -1466,3 +1538,156 @@
|
|||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Meeting banner actions */
|
||||||
|
.tscm-meeting-banner {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tscm-meeting-banner .meeting-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Case linking */
|
||||||
|
.tscm-case-link-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: rgba(74, 158, 255, 0.12);
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.case-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.tscm-case-link-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
color: #9ed0ff;
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schedules */
|
||||||
|
.tscm-schedule-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.tscm-schedule-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.tscm-schedule-item {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.tscm-schedule-item.enabled {
|
||||||
|
border-color: rgba(0, 255, 136, 0.35);
|
||||||
|
}
|
||||||
|
.tscm-schedule-item.disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.tscm-schedule-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.tscm-schedule-status {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.tscm-schedule-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.tscm-schedule-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meeting summary */
|
||||||
|
.tscm-summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tscm-summary-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.tscm-summary-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.tscm-summary-risk {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #ff9933;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Case notes */
|
||||||
|
.tscm-case-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tscm-case-note {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.tscm-case-note-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.tscm-case-note-type {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.tscm-case-note-content {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.tscm-case-note-author {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.tscm-case-note-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.tscm-case-note-form textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.tscm-case-note-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -114,7 +114,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: min(320px, 85vw);
|
width: min(360px, 100vw);
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
height: 100vh; /* Fallback */
|
height: 100vh; /* Fallback */
|
||||||
background: var(--bg-secondary, #0f1218);
|
background: var(--bg-secondary, #0f1218);
|
||||||
@@ -381,6 +381,33 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-panel {
|
||||||
|
min-height: 58vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls .stats {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
/* Container should not clip content */
|
/* Container should not clip content */
|
||||||
.container {
|
.container {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* Satellite Dashboard Styles
|
|
||||||
* Imports design tokens for consistency, with dashboard-specific overrides
|
|
||||||
*/
|
|
||||||
@import url('./core/variables.css');
|
|
||||||
@import url('./core/layout.css');
|
|
||||||
@import url('./core/components.css');
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -13,16 +5,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dashboard-specific aliases (for backward compatibility) */
|
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-dark: var(--bg-primary);
|
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||||
--bg-panel: var(--bg-secondary);
|
--bg-dark: #0b1118;
|
||||||
--bg-card: var(--bg-tertiary);
|
--bg-panel: #101823;
|
||||||
/* Use tokens for shared values, keep custom values for dashboard-specific */
|
--bg-card: #151f2b;
|
||||||
--grid-line: rgba(74, 158, 255, 0.08);
|
--border-color: #263246;
|
||||||
|
--border-glow: #4aa3ff;
|
||||||
|
--text-primary: #d7e0ee;
|
||||||
|
--text-secondary: #9fb0c7;
|
||||||
|
--text-dim: #6f7f94;
|
||||||
|
--accent-cyan: #4aa3ff;
|
||||||
|
--accent-green: #38c180;
|
||||||
|
--accent-orange: #d6a85e;
|
||||||
|
--accent-red: #e25d5d;
|
||||||
|
--accent-purple: #8f7bd6;
|
||||||
|
--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 {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -37,9 +41,10 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-image:
|
background-image:
|
||||||
|
var(--noise-image),
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
background-size: 50px 50px;
|
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||||
animation: gridMove 20s linear infinite;
|
animation: gridMove 20s linear infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
@@ -61,12 +66,14 @@ body {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 4px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
animation: scan 3s linear infinite;
|
color: var(--accent-cyan);
|
||||||
|
animation: scan 6s linear infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
opacity: 0.5;
|
opacity: 0.25;
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan {
|
@keyframes scan {
|
||||||
@@ -91,8 +98,20 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.logo {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
@@ -141,7 +160,7 @@ body {
|
|||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,10 +180,45 @@ body {
|
|||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-select {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-selector .location-status-dot.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
box-shadow: 0 0 6px var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item {
|
.status-item {
|
||||||
@@ -210,7 +264,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid */
|
/* Main dashboard grid */
|
||||||
/* Header ~60px + Controls bar ~55px = ~115px */
|
/* Header ~52px + Nav 44px = ~96px, using 100px for safety */
|
||||||
.dashboard {
|
.dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -218,8 +272,7 @@ body {
|
|||||||
grid-template-columns: 1fr 1fr 340px;
|
grid-template-columns: 1fr 1fr 340px;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100dvh - 115px);
|
height: calc(100vh - 100px);
|
||||||
height: calc(100vh - 115px); /* Fallback */
|
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +511,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-value {
|
.telemetry-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
@@ -544,7 +597,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pass-time {
|
.pass-time {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom controls bar */
|
/* Bottom controls bar */
|
||||||
@@ -580,7 +633,7 @@ body {
|
|||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,7 +750,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: calc(100dvh - 115px);
|
min-height: calc(100vh - 100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.polar-container,
|
.polar-container,
|
||||||
|
|||||||
+126
-5
@@ -24,8 +24,11 @@
|
|||||||
background: var(--bg-dark, #0a0a0f);
|
background: var(--bg-dark, #0a0a0f);
|
||||||
border: 1px solid var(--border-color, #1a1a2e);
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: 600px;
|
max-width: 900px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
@@ -71,22 +74,28 @@
|
|||||||
|
|
||||||
/* Settings Tabs */
|
/* Settings Tabs */
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
gap: 4px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 16px;
|
padding: 12px 10px;
|
||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab:hover {
|
.settings-tab:hover {
|
||||||
@@ -115,6 +124,9 @@
|
|||||||
|
|
||||||
.settings-section.active {
|
.settings-section.active {
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group {
|
.settings-group {
|
||||||
@@ -163,6 +175,47 @@
|
|||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings Feed Lists */
|
||||||
|
.settings-feed {
|
||||||
|
background: var(--bg-tertiary, #12121f);
|
||||||
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-meta {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-empty {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toggle Switch */
|
/* Toggle Switch */
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -326,6 +379,23 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GPS Detection Spinner */
|
||||||
|
.detecting-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: detecting-spin 0.8s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes detecting-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
/* About Section */
|
/* About Section */
|
||||||
.about-info {
|
.about-info {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -347,10 +417,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-version {
|
.about-version {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Donate Button */
|
||||||
|
.donate-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #000;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tile Provider Custom URL */
|
/* Tile Provider Custom URL */
|
||||||
.custom-url-row {
|
.custom-url-row {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -376,7 +474,18 @@
|
|||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Map tile variants */
|
||||||
|
.tile-layer-cyan {
|
||||||
|
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.settings-tabs {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settings-modal.active {
|
.settings-modal.active {
|
||||||
padding: 20px 10px;
|
padding: 20px 10px;
|
||||||
@@ -386,6 +495,18 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
padding: 0 10px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 10px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-row {
|
.settings-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const ProximityRadar = (function() {
|
|||||||
let activeFilter = null;
|
let activeFilter = null;
|
||||||
let onDeviceClick = null;
|
let onDeviceClick = null;
|
||||||
let selectedDeviceKey = null;
|
let selectedDeviceKey = null;
|
||||||
|
let isHovered = false;
|
||||||
|
let renderPending = false;
|
||||||
|
let renderTimer = null;
|
||||||
|
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the radar component
|
* Initialize the radar component
|
||||||
@@ -116,6 +120,36 @@ const ProximityRadar = (function() {
|
|||||||
|
|
||||||
svg = container.querySelector('svg');
|
svg = container.querySelector('svg');
|
||||||
|
|
||||||
|
// Event delegation on the devices group (survives innerHTML rebuilds)
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('click', (e) => {
|
||||||
|
const deviceEl = e.target.closest('.radar-device');
|
||||||
|
if (!deviceEl) return;
|
||||||
|
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||||
|
if (onDeviceClick && deviceKey) {
|
||||||
|
// Lock out re-renders briefly so the DOM stays stable after click
|
||||||
|
interactionLockUntil = Date.now() + 500;
|
||||||
|
onDeviceClick(deviceKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseenter', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = true;
|
||||||
|
}
|
||||||
|
}, true); // capture phase so we catch enter on child elements
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseleave', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = false;
|
||||||
|
if (renderPending) {
|
||||||
|
renderPending = false;
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Add sweep animation
|
// Add sweep animation
|
||||||
animateSweep();
|
animateSweep();
|
||||||
}
|
}
|
||||||
@@ -162,8 +196,18 @@ const ProximityRadar = (function() {
|
|||||||
devices.set(device.device_key, device);
|
devices.set(device.device_key, device);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply filter and render
|
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||||
renderDevices();
|
if (isHovered || Date.now() < interactionLockUntil) {
|
||||||
|
renderPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce rapid updates (e.g. per-device SSE events)
|
||||||
|
if (renderTimer) clearTimeout(renderTimer);
|
||||||
|
renderTimer = setTimeout(() => {
|
||||||
|
renderTimer = null;
|
||||||
|
renderDevices();
|
||||||
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,33 +251,30 @@ const ProximityRadar = (function() {
|
|||||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||||
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
||||||
|
|
||||||
|
// Hit area size (prevents hover flicker when scaling)
|
||||||
|
const hitAreaSize = Math.max(dotSize * 2, 15);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
<g transform="translate(${x}, ${y})">
|
||||||
transform="translate(${x}, ${y})" style="cursor: pointer;">
|
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
style="cursor: pointer;">
|
||||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
<!-- Invisible hit area to prevent hover flicker -->
|
||||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||||
</circle>` : ''}
|
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||||
<circle r="${dotSize}" fill="${color}"
|
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||||
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
|
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||||
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
|
</circle>` : ''}
|
||||||
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
<circle r="${dotSize}" fill="${color}"
|
||||||
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
|
||||||
|
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
|
||||||
|
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
||||||
|
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
devicesGroup.innerHTML = dots;
|
devicesGroup.innerHTML = dots;
|
||||||
|
|
||||||
// Attach click handlers
|
|
||||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
|
||||||
el.addEventListener('click', (e) => {
|
|
||||||
const deviceKey = el.getAttribute('data-device-key');
|
|
||||||
if (onDeviceClick && deviceKey) {
|
|
||||||
onDeviceClick(deviceKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -317,19 +358,125 @@ const ProximityRadar = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight a specific device on the radar
|
* Highlight a specific device on the radar (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function highlightDevice(deviceKey) {
|
function highlightDevice(deviceKey) {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = deviceKey;
|
selectedDeviceKey = deviceKey;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
// Remove highlight from previously selected node
|
||||||
|
if (prev && prev !== deviceKey) {
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
// Remove animated selection ring
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
// Restore dot opacity
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add highlight to newly selected node
|
||||||
|
if (deviceKey) {
|
||||||
|
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
|
||||||
|
if (newEl) {
|
||||||
|
applySelectionToElement(newEl, deviceKey);
|
||||||
|
} else {
|
||||||
|
// Node not in DOM yet; full render needed on next cycle
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device highlighting
|
* Apply selection styling to a radar device element in-place
|
||||||
|
*/
|
||||||
|
function applySelectionToElement(el, deviceKey) {
|
||||||
|
el.classList.add('selected');
|
||||||
|
const device = devices.get(deviceKey);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||||
|
|
||||||
|
// Update dot styling
|
||||||
|
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
dot.setAttribute('fill-opacity', '1');
|
||||||
|
dot.setAttribute('stroke', '#00d4ff');
|
||||||
|
dot.setAttribute('stroke-width', '2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animated selection ring if not already present
|
||||||
|
if (!el.querySelector('.radar-select-ring')) {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
const ring = document.createElementNS(ns, 'circle');
|
||||||
|
ring.classList.add('radar-select-ring');
|
||||||
|
ring.setAttribute('r', dotSize + 8);
|
||||||
|
ring.setAttribute('fill', 'none');
|
||||||
|
ring.setAttribute('stroke', '#00d4ff');
|
||||||
|
ring.setAttribute('stroke-width', '2');
|
||||||
|
ring.setAttribute('stroke-opacity', '0.8');
|
||||||
|
|
||||||
|
const animR = document.createElementNS(ns, 'animate');
|
||||||
|
animR.setAttribute('attributeName', 'r');
|
||||||
|
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
|
||||||
|
animR.setAttribute('dur', '1.5s');
|
||||||
|
animR.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animR);
|
||||||
|
|
||||||
|
const animO = document.createElementNS(ns, 'animate');
|
||||||
|
animO.setAttribute('attributeName', 'stroke-opacity');
|
||||||
|
animO.setAttribute('values', '0.8;0.4;0.8');
|
||||||
|
animO.setAttribute('dur', '1.5s');
|
||||||
|
animO.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animO);
|
||||||
|
|
||||||
|
// Insert after the hit area
|
||||||
|
const hitArea = el.querySelector('.radar-device-hitarea');
|
||||||
|
if (hitArea && hitArea.nextSibling) {
|
||||||
|
el.insertBefore(ring, hitArea.nextSibling);
|
||||||
|
} else {
|
||||||
|
el.insertBefore(ring, el.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear device highlighting (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function clearHighlight() {
|
function clearHighlight() {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = null;
|
selectedDeviceKey = null;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg || !prev) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -302,7 +302,13 @@ const SignalCards = (function() {
|
|||||||
*/
|
*/
|
||||||
function formatRelativeTime(timestamp) {
|
function formatRelativeTime(timestamp) {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
const date = new Date(timestamp);
|
let date = new Date(timestamp);
|
||||||
|
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
|
||||||
|
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
|
||||||
|
const today = new Date();
|
||||||
|
date = new Date(today.toDateString() + ' ' + timestamp);
|
||||||
|
}
|
||||||
|
if (isNaN(date.getTime())) return timestamp;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = Math.floor((now - date) / 1000);
|
const diff = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -868,11 +868,8 @@ function connectAgentStream(mode, onMessage) {
|
|||||||
if (currentAgent === 'local') {
|
if (currentAgent === 'local') {
|
||||||
streamUrl = `/${mode}/stream`;
|
streamUrl = `/${mode}/stream`;
|
||||||
} else {
|
} else {
|
||||||
// For remote agents, we could either:
|
// For remote agents, proxy SSE through controller
|
||||||
// 1. Use the multi-agent stream: /controller/stream/all
|
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
|
||||||
// 2. Or proxy through controller (not implemented yet)
|
|
||||||
// For now, use multi-agent stream which includes agent_name tagging
|
|
||||||
streamUrl = '/controller/stream/all';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
agentEventSource = new EventSource(streamUrl);
|
agentEventSource = new EventSource(streamUrl);
|
||||||
@@ -881,14 +878,6 @@ function connectAgentStream(mode, onMessage) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
// If using multi-agent stream, filter by current agent if needed
|
|
||||||
if (streamUrl === '/controller/stream/all' && currentAgent !== 'local') {
|
|
||||||
const agent = agents.find(a => a.id == currentAgent);
|
|
||||||
if (agent && data.agent_name && data.agent_name !== agent.name) {
|
|
||||||
return; // Skip messages from other agents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMessage(data);
|
onMessage(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing SSE message:', e);
|
console.error('Error parsing SSE message:', e);
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
const AlertCenter = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let alerts = [];
|
||||||
|
let rules = [];
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
loadRules();
|
||||||
|
loadFeed();
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
eventSource = new EventSource('/alerts/stream');
|
||||||
|
eventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'keepalive') return;
|
||||||
|
handleAlert(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Alerts] SSE parse error', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
console.warn('[Alerts] SSE connection error');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlert(alert) {
|
||||||
|
alerts.unshift(alert);
|
||||||
|
alerts = alerts.slice(0, 50);
|
||||||
|
updateFeedUI();
|
||||||
|
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
const severity = (alert.severity || '').toLowerCase();
|
||||||
|
if (['high', 'critical'].includes(severity)) {
|
||||||
|
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeedUI() {
|
||||||
|
const list = document.getElementById('alertsFeedList');
|
||||||
|
const countEl = document.getElementById('alertsFeedCount');
|
||||||
|
if (countEl) countEl.textContent = `(${alerts.length})`;
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = alerts.map(alert => {
|
||||||
|
const title = escapeHtml(alert.title || 'Alert');
|
||||||
|
const message = escapeHtml(alert.message || '');
|
||||||
|
const severity = escapeHtml(alert.severity || 'medium');
|
||||||
|
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${title}</span>
|
||||||
|
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">${message}</div>
|
||||||
|
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFeed() {
|
||||||
|
fetch('/alerts/events?limit=20')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alerts = data.events || [];
|
||||||
|
updateFeedUI();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Alerts] Load feed failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRules() {
|
||||||
|
fetch('/alerts/rules?all=1')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
rules = data.rules || [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Alerts] Load rules failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableTrackerAlerts() {
|
||||||
|
ensureTrackerRule(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableTrackerAlerts() {
|
||||||
|
ensureTrackerRule(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrackerRule(enabled) {
|
||||||
|
loadRules();
|
||||||
|
setTimeout(() => {
|
||||||
|
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
||||||
|
if (existing) {
|
||||||
|
fetch(`/alerts/rules/${existing.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
}).then(() => loadRules());
|
||||||
|
} else if (enabled) {
|
||||||
|
fetch('/alerts/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: TRACKER_RULE_NAME,
|
||||||
|
mode: 'bluetooth',
|
||||||
|
event_type: 'device_update',
|
||||||
|
match: { is_tracker: true },
|
||||||
|
severity: 'high',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true }
|
||||||
|
})
|
||||||
|
}).then(() => loadRules());
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBluetoothWatchlist(address, name) {
|
||||||
|
if (!address) return;
|
||||||
|
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/alerts/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
||||||
|
mode: 'bluetooth',
|
||||||
|
event_type: 'device_update',
|
||||||
|
match: { address: address },
|
||||||
|
severity: 'medium',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true }
|
||||||
|
})
|
||||||
|
}).then(() => loadRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBluetoothWatchlist(address) {
|
||||||
|
if (!address) return;
|
||||||
|
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||||
|
if (!existing) return;
|
||||||
|
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||||
|
.then(() => loadRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWatchlisted(address) {
|
||||||
|
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
loadFeed,
|
||||||
|
enableTrackerAlerts,
|
||||||
|
disableTrackerAlerts,
|
||||||
|
addBluetoothWatchlist,
|
||||||
|
removeBluetoothWatchlist,
|
||||||
|
isWatchlisted,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
|
AlertCenter.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
+13
-8
@@ -32,6 +32,9 @@ let muted = localStorage.getItem('audioMuted') === 'true';
|
|||||||
|
|
||||||
// Observer location (load from localStorage or default to London)
|
// Observer location (load from localStorage or default to London)
|
||||||
let observerLocation = (function() {
|
let observerLocation = (function() {
|
||||||
|
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||||||
|
return ObserverLocation.getForModule('observerLocation');
|
||||||
|
}
|
||||||
const saved = localStorage.getItem('observerLocation');
|
const saved = localStorage.getItem('observerLocation');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
@@ -243,10 +246,10 @@ function toggleSection(el) {
|
|||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
const currentTheme = html.getAttribute('data-theme');
|
const currentTheme = html.getAttribute('data-theme') || 'dark';
|
||||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||||
html.setAttribute('data-theme', newTheme);
|
html.setAttribute('data-theme', newTheme);
|
||||||
localStorage.setItem('theme', newTheme);
|
localStorage.setItem('intercept-theme', newTheme);
|
||||||
|
|
||||||
// Update button text
|
// Update button text
|
||||||
const btn = document.getElementById('themeToggle');
|
const btn = document.getElementById('themeToggle');
|
||||||
@@ -256,7 +259,7 @@ function toggleTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadTheme() {
|
function loadTheme() {
|
||||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
const savedTheme = localStorage.getItem('intercept-theme') || 'dark';
|
||||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
const btn = document.getElementById('themeToggle');
|
const btn = document.getElementById('themeToggle');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@@ -370,7 +373,7 @@ function showInfo(text) {
|
|||||||
|
|
||||||
const infoEl = document.createElement('div');
|
const infoEl = document.createElement('div');
|
||||||
infoEl.className = 'info-msg';
|
infoEl.className = 'info-msg';
|
||||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Space Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
||||||
infoEl.textContent = text;
|
infoEl.textContent = text;
|
||||||
output.insertBefore(infoEl, output.firstChild);
|
output.insertBefore(infoEl, output.firstChild);
|
||||||
}
|
}
|
||||||
@@ -384,7 +387,7 @@ function showError(text) {
|
|||||||
|
|
||||||
const errorEl = document.createElement('div');
|
const errorEl = document.createElement('div');
|
||||||
errorEl.className = 'error-msg';
|
errorEl.className = 'error-msg';
|
||||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Space Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||||
errorEl.textContent = '⚠ ' + text;
|
errorEl.textContent = '⚠ ' + text;
|
||||||
output.insertBefore(errorEl, output.firstChild);
|
output.insertBefore(errorEl, output.firstChild);
|
||||||
}
|
}
|
||||||
@@ -485,10 +488,12 @@ function initApp() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collapse all sections by default (except SDR Device which is first)
|
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
|
||||||
document.querySelectorAll('.section').forEach((section, index) => {
|
document.querySelectorAll('.sidebar .section').forEach((section) => {
|
||||||
if (index > 0) {
|
if (section.querySelector('h3')) {
|
||||||
section.classList.add('collapsed');
|
section.classList.add('collapsed');
|
||||||
|
} else {
|
||||||
|
section.classList.remove('collapsed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
(() => {
|
||||||
|
const dropdowns = Array.from(document.querySelectorAll('.mode-nav-dropdown'));
|
||||||
|
if (!dropdowns.length) return;
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
dropdowns.forEach((dropdown) => dropdown.classList.remove('open'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDropdown = (dropdown) => {
|
||||||
|
if (!dropdown.classList.contains('open')) {
|
||||||
|
closeAll();
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const menuLink = event.target.closest('.mode-nav-dropdown-menu a');
|
||||||
|
if (menuLink) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
window.location.href = menuLink.href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = event.target.closest('.mode-nav-dropdown-btn');
|
||||||
|
if (button) {
|
||||||
|
event.preventDefault();
|
||||||
|
const dropdown = button.closest('.mode-nav-dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
if (dropdown.classList.contains('open')) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
openDropdown(dropdown);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest('.mode-nav-dropdown')) {
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Shared observer location helper for map-based modules.
|
||||||
|
// Default: shared location enabled unless explicitly disabled via config.
|
||||||
|
window.ObserverLocation = (function() {
|
||||||
|
const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
|
||||||
|
? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
|
||||||
|
: { lat: 51.5074, lon: -0.1278 };
|
||||||
|
const SHARED_KEY = 'observerLocation';
|
||||||
|
const AIS_KEY = 'ais_observerLocation';
|
||||||
|
const LEGACY_LAT_KEY = 'observerLat';
|
||||||
|
const LEGACY_LON_KEY = 'observerLon';
|
||||||
|
|
||||||
|
function isSharedEnabled() {
|
||||||
|
return window.INTERCEPT_SHARED_OBSERVER_LOCATION !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(lat, lon) {
|
||||||
|
const latNum = parseFloat(lat);
|
||||||
|
const lonNum = parseFloat(lon);
|
||||||
|
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null;
|
||||||
|
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null;
|
||||||
|
return { lat: latNum, lon: lonNum };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocation(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && parsed.lat !== undefined && parsed.lon !== undefined) {
|
||||||
|
return normalize(parsed.lat, parsed.lon);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readKey(key) {
|
||||||
|
return parseLocation(localStorage.getItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacyLatLon() {
|
||||||
|
const lat = localStorage.getItem(LEGACY_LAT_KEY);
|
||||||
|
const lon = localStorage.getItem(LEGACY_LON_KEY);
|
||||||
|
if (!lat || !lon) return null;
|
||||||
|
return normalize(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStoredLocation() {
|
||||||
|
return !!(readKey(SHARED_KEY) || readKey(AIS_KEY) || readLegacyLatLon());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShared() {
|
||||||
|
const current = readKey(SHARED_KEY);
|
||||||
|
if (current) return current;
|
||||||
|
|
||||||
|
const legacy = readKey(AIS_KEY) || readLegacyLatLon();
|
||||||
|
if (legacy) {
|
||||||
|
setShared(legacy);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_LOCATION };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShared(location, options = {}) {
|
||||||
|
if (!location) return;
|
||||||
|
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
|
||||||
|
if (options.updateLegacy !== false) {
|
||||||
|
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||||
|
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForModule(moduleKey, options = {}) {
|
||||||
|
if (isSharedEnabled()) {
|
||||||
|
return getShared();
|
||||||
|
}
|
||||||
|
if (moduleKey) {
|
||||||
|
const moduleLocation = readKey(moduleKey);
|
||||||
|
if (moduleLocation) return moduleLocation;
|
||||||
|
}
|
||||||
|
if (options.fallbackToLatLon) {
|
||||||
|
const legacy = readLegacyLatLon();
|
||||||
|
if (legacy) return legacy;
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_LOCATION };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setForModule(moduleKey, location, options = {}) {
|
||||||
|
if (!location) return;
|
||||||
|
if (isSharedEnabled()) {
|
||||||
|
setShared(location, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (moduleKey) {
|
||||||
|
localStorage.setItem(moduleKey, JSON.stringify(location));
|
||||||
|
} else if (options.fallbackToLatLon) {
|
||||||
|
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||||
|
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSharedEnabled,
|
||||||
|
hasStoredLocation,
|
||||||
|
getShared,
|
||||||
|
setShared,
|
||||||
|
getForModule,
|
||||||
|
setForModule,
|
||||||
|
normalize,
|
||||||
|
DEFAULT_LOCATION: { ...DEFAULT_LOCATION }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const RecordingUI = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let recordings = [];
|
||||||
|
let active = [];
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
fetch('/recordings')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
recordings = data.recordings || [];
|
||||||
|
active = data.active || [];
|
||||||
|
renderActive();
|
||||||
|
renderRecordings();
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Recording] Load failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const modeSelect = document.getElementById('recordingModeSelect');
|
||||||
|
const labelInput = document.getElementById('recordingLabelInput');
|
||||||
|
const mode = modeSelect ? modeSelect.value : '';
|
||||||
|
const label = labelInput ? labelInput.value : '';
|
||||||
|
if (!mode) return;
|
||||||
|
|
||||||
|
fetch('/recordings/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode, label })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Recording] Start failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
const modeSelect = document.getElementById('recordingModeSelect');
|
||||||
|
const mode = modeSelect ? modeSelect.value : '';
|
||||||
|
if (!mode) return;
|
||||||
|
|
||||||
|
fetch('/recordings/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh())
|
||||||
|
.catch(err => console.error('[Recording] Stop failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopById(sessionId) {
|
||||||
|
fetch('/recordings/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: sessionId })
|
||||||
|
}).then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActive() {
|
||||||
|
const container = document.getElementById('recordingActiveList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!active.length) {
|
||||||
|
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = active.map(session => {
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${escapeHtml(session.mode)}</span>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
|
||||||
|
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordings() {
|
||||||
|
const container = document.getElementById('recordingList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!recordings.length) {
|
||||||
|
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = recordings.map(rec => {
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||||
|
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(sessionId) {
|
||||||
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
refresh,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
stopById,
|
||||||
|
download,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
|
RecordingUI.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ const Settings = {
|
|||||||
'offline.enabled': false,
|
'offline.enabled': false,
|
||||||
'offline.assets_source': 'cdn',
|
'offline.assets_source': 'cdn',
|
||||||
'offline.fonts_source': 'cdn',
|
'offline.fonts_source': 'cdn',
|
||||||
'offline.tile_provider': 'cartodb_dark',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -24,6 +24,14 @@ const Settings = {
|
|||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
subdomains: 'abcd'
|
subdomains: 'abcd'
|
||||||
},
|
},
|
||||||
|
cartodb_dark_cyan: {
|
||||||
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
options: {
|
||||||
|
className: 'tile-layer-cyan'
|
||||||
|
}
|
||||||
|
},
|
||||||
cartodb_light: {
|
cartodb_light: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
@@ -213,7 +221,8 @@ const Settings = {
|
|||||||
const config = this.getTileConfig();
|
const config = this.getTileConfig();
|
||||||
const options = {
|
const options = {
|
||||||
attribution: config.attribution,
|
attribution: config.attribution,
|
||||||
maxZoom: 19
|
maxZoom: 19,
|
||||||
|
...(config.options || {})
|
||||||
};
|
};
|
||||||
if (config.subdomains) {
|
if (config.subdomains) {
|
||||||
options.subdomains = config.subdomains;
|
options.subdomains = config.subdomains;
|
||||||
@@ -325,12 +334,12 @@ const Settings = {
|
|||||||
window.map,
|
window.map,
|
||||||
window.leafletMap,
|
window.leafletMap,
|
||||||
window.aprsMap,
|
window.aprsMap,
|
||||||
window.adsbMap,
|
|
||||||
window.radarMap,
|
window.radarMap,
|
||||||
window.vesselMap,
|
window.vesselMap,
|
||||||
window.groundMap,
|
window.groundMap,
|
||||||
window.groundTrackMap,
|
window.groundTrackMap,
|
||||||
window.meshMap
|
window.meshMap,
|
||||||
|
window.issMap
|
||||||
].filter(m => m && typeof m.eachLayer === 'function');
|
].filter(m => m && typeof m.eachLayer === 'function');
|
||||||
|
|
||||||
// Combine with registered maps, removing duplicates
|
// Combine with registered maps, removing duplicates
|
||||||
@@ -351,7 +360,8 @@ const Settings = {
|
|||||||
// Add new tile layer
|
// Add new tile layer
|
||||||
const options = {
|
const options = {
|
||||||
attribution: config.attribution,
|
attribution: config.attribution,
|
||||||
maxZoom: 19
|
maxZoom: 19,
|
||||||
|
...(config.options || {})
|
||||||
};
|
};
|
||||||
if (config.subdomains) {
|
if (config.subdomains) {
|
||||||
options.subdomains = config.subdomains;
|
options.subdomains = config.subdomains;
|
||||||
@@ -548,8 +558,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
* Load and display current observer location
|
* Load and display current observer location
|
||||||
*/
|
*/
|
||||||
function loadObserverLocation() {
|
function loadObserverLocation() {
|
||||||
const lat = localStorage.getItem('observerLat');
|
let lat = localStorage.getItem('observerLat');
|
||||||
const lon = localStorage.getItem('observerLon');
|
let lon = localStorage.getItem('observerLon');
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
lat = shared.lat.toString();
|
||||||
|
lon = shared.lon.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const latInput = document.getElementById('observerLatInput');
|
const latInput = document.getElementById('observerLatInput');
|
||||||
const lonInput = document.getElementById('observerLonInput');
|
const lonInput = document.getElementById('observerLonInput');
|
||||||
@@ -565,58 +580,104 @@ function loadObserverLocation() {
|
|||||||
if (currentLonDisplay) {
|
if (currentLonDisplay) {
|
||||||
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
|
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync dashboard-specific location keys for backward compatibility
|
||||||
|
if (lat && lon) {
|
||||||
|
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||||
|
if (!localStorage.getItem('observerLocation')) {
|
||||||
|
localStorage.setItem('observerLocation', locationObj);
|
||||||
|
}
|
||||||
|
if (!localStorage.getItem('ais_observerLocation')) {
|
||||||
|
localStorage.setItem('ais_observerLocation', locationObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect location using browser GPS
|
* Detect location using gpsd (USB GPS) or browser geolocation as fallback
|
||||||
*/
|
*/
|
||||||
function detectLocationGPS(btn) {
|
function detectLocationGPS(btn) {
|
||||||
const latInput = document.getElementById('observerLatInput');
|
const latInput = document.getElementById('observerLatInput');
|
||||||
const lonInput = document.getElementById('observerLonInput');
|
const lonInput = document.getElementById('observerLonInput');
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
// Show loading state with visual feedback
|
||||||
if (typeof showNotification === 'function') {
|
const originalText = btn.innerHTML;
|
||||||
showNotification('Location', 'GPS not available in this browser');
|
btn.innerHTML = '<span class="detecting-spinner"></span> Detecting...';
|
||||||
} else {
|
btn.disabled = true;
|
||||||
alert('GPS not available in this browser');
|
btn.style.opacity = '0.7';
|
||||||
}
|
|
||||||
return;
|
// Helper to restore button state
|
||||||
|
function restoreButton() {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.style.opacity = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Helper to set location values
|
||||||
const originalText = btn.innerHTML;
|
function setLocation(lat, lon, source) {
|
||||||
btn.innerHTML = '<span style="opacity: 0.7;">Detecting...</span>';
|
if (latInput) latInput.value = parseFloat(lat).toFixed(4);
|
||||||
btn.disabled = true;
|
if (lonInput) lonInput.value = parseFloat(lon).toFixed(4);
|
||||||
|
restoreButton();
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Location', `Coordinates set from ${source}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
// First, try gpsd (USB GPS device)
|
||||||
(pos) => {
|
fetch('/gps/position')
|
||||||
if (latInput) latInput.value = pos.coords.latitude.toFixed(4);
|
.then(response => response.json())
|
||||||
if (lonInput) lonInput.value = pos.coords.longitude.toFixed(4);
|
.then(data => {
|
||||||
|
if (data.status === 'ok' && data.position && data.position.latitude != null) {
|
||||||
btn.innerHTML = originalText;
|
// Got valid position from gpsd
|
||||||
btn.disabled = false;
|
setLocation(data.position.latitude, data.position.longitude, 'GPS device');
|
||||||
|
} else if (data.status === 'waiting') {
|
||||||
if (typeof showNotification === 'function') {
|
// gpsd connected but no fix yet - show message and try browser
|
||||||
showNotification('Location', 'GPS coordinates detected');
|
if (typeof showNotification === 'function') {
|
||||||
}
|
showNotification('GPS', 'GPS device connected but no fix yet. Trying browser location...');
|
||||||
},
|
}
|
||||||
(err) => {
|
useBrowserGeolocation();
|
||||||
btn.innerHTML = originalText;
|
|
||||||
btn.disabled = false;
|
|
||||||
|
|
||||||
let message = 'Failed to get location';
|
|
||||||
if (err.code === 1) message = 'Location access denied';
|
|
||||||
else if (err.code === 2) message = 'Location unavailable';
|
|
||||||
else if (err.code === 3) message = 'Location request timed out';
|
|
||||||
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Location', message);
|
|
||||||
} else {
|
} else {
|
||||||
alert(message);
|
// gpsd not available, try browser geolocation
|
||||||
|
useBrowserGeolocation();
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{ enableHighAccuracy: true, timeout: 10000 }
|
.catch(() => {
|
||||||
);
|
// gpsd request failed, try browser geolocation
|
||||||
|
useBrowserGeolocation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to browser geolocation
|
||||||
|
function useBrowserGeolocation() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
restoreButton();
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Location', 'No GPS available (gpsd not running, browser GPS unavailable)');
|
||||||
|
} else {
|
||||||
|
alert('No GPS available');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setLocation(pos.coords.latitude, pos.coords.longitude, 'browser');
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
restoreButton();
|
||||||
|
let message = 'Failed to get location';
|
||||||
|
if (err.code === 1) message = 'Location access denied';
|
||||||
|
else if (err.code === 2) message = 'Location unavailable';
|
||||||
|
else if (err.code === 3) message = 'Location request timed out';
|
||||||
|
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Location', message);
|
||||||
|
} else {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -647,8 +708,17 @@ function saveObserverLocation() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('observerLat', lat.toString());
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
localStorage.setItem('observerLon', lon.toString());
|
ObserverLocation.setShared({ lat, lon });
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('observerLat', lat.toString());
|
||||||
|
localStorage.setItem('observerLon', lon.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update dashboard-specific location keys for ADS-B and AIS
|
||||||
|
const locationObj = JSON.stringify({ lat: lat, lon: lon });
|
||||||
|
localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard
|
||||||
|
localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard
|
||||||
|
|
||||||
// Update display
|
// Update display
|
||||||
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
||||||
@@ -660,6 +730,11 @@ function saveObserverLocation() {
|
|||||||
showNotification('Location', 'Observer location saved');
|
showNotification('Location', 'Observer location saved');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.observerLocation) {
|
||||||
|
window.observerLocation.lat = lat;
|
||||||
|
window.observerLocation.lon = lon;
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh SSTV ISS schedule if available
|
// Refresh SSTV ISS schedule if available
|
||||||
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
|
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
|
||||||
SSTV.loadIssSchedule();
|
SSTV.loadIssSchedule();
|
||||||
@@ -677,6 +752,11 @@ async function checkForUpdatesManual() {
|
|||||||
const content = document.getElementById('updateStatusContent');
|
const content = document.getElementById('updateStatusContent');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
|
if (typeof Updater === 'undefined') {
|
||||||
|
content.innerHTML = `<div style="color: var(--text-dim); padding: 10px;">Update checking is unavailable. If you use a content blocker, try allowing <code>updater.js</code> to load.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
content.innerHTML = '<div style="text-align: center; padding: 20px; color: var(--text-dim);">Checking for updates...</div>';
|
content.innerHTML = '<div style="text-align: center; padding: 20px; color: var(--text-dim);">Checking for updates...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -694,6 +774,11 @@ async function loadUpdateStatus() {
|
|||||||
const content = document.getElementById('updateStatusContent');
|
const content = document.getElementById('updateStatusContent');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
|
if (typeof Updater === 'undefined') {
|
||||||
|
content.innerHTML = `<div style="color: var(--text-dim); padding: 10px;">Update checking is unavailable. If you use a content blocker, try allowing <code>updater.js</code> to load.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await Updater.getStatus();
|
const data = await Updater.getStatus();
|
||||||
renderUpdateStatus(data);
|
renderUpdateStatus(data);
|
||||||
@@ -748,11 +833,11 @@ function renderUpdateStatus(data) {
|
|||||||
<div style="display: grid; gap: 8px; font-size: 12px;">
|
<div style="display: grid; gap: 8px; font-size: 12px;">
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-dim);">Current Version</span>
|
<span style="color: var(--text-dim);">Current Version</span>
|
||||||
<span style="font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">v${data.current_version}</span>
|
<span style="font-family: 'Space Mono', monospace; color: var(--text-primary);">v${data.current_version}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-dim);">Latest Version</span>
|
<span style="color: var(--text-dim);">Latest Version</span>
|
||||||
<span style="font-family: 'JetBrains Mono', monospace; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
<span style="font-family: 'Space Mono', monospace; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
||||||
</div>
|
</div>
|
||||||
${data.last_check ? `
|
${data.last_check ? `
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
@@ -837,5 +922,64 @@ function switchSettingsTab(tabName) {
|
|||||||
loadUpdateStatus();
|
loadUpdateStatus();
|
||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
|
} else if (tabName === 'alerts') {
|
||||||
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
|
AlertCenter.loadFeed();
|
||||||
|
}
|
||||||
|
} else if (tabName === 'recording') {
|
||||||
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
|
RecordingUI.refresh();
|
||||||
|
}
|
||||||
|
} else if (tabName === 'apikeys') {
|
||||||
|
loadApiKeyStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load API key status into the API Keys settings tab
|
||||||
|
*/
|
||||||
|
function loadApiKeyStatus() {
|
||||||
|
const badge = document.getElementById('apiKeyStatusBadge');
|
||||||
|
const desc = document.getElementById('apiKeyStatusDesc');
|
||||||
|
const usage = document.getElementById('apiKeyUsageCount');
|
||||||
|
const bar = document.getElementById('apiKeyUsageBar');
|
||||||
|
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
badge.textContent = 'Not available';
|
||||||
|
badge.className = 'asset-badge missing';
|
||||||
|
desc.textContent = 'GSM feature removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save API key from the settings input
|
||||||
|
*/
|
||||||
|
function saveApiKey() {
|
||||||
|
const input = document.getElementById('apiKeyInput');
|
||||||
|
const result = document.getElementById('apiKeySaveResult');
|
||||||
|
if (!input || !result) return;
|
||||||
|
|
||||||
|
const key = input.value.trim();
|
||||||
|
if (!key) {
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.style.color = 'var(--accent-red)';
|
||||||
|
result.textContent = 'Please enter an API key.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.style.color = 'var(--text-dim)';
|
||||||
|
result.textContent = 'Saving...';
|
||||||
|
|
||||||
|
result.style.color = 'var(--accent-red)';
|
||||||
|
result.textContent = 'GSM feature has been removed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle API key input visibility
|
||||||
|
*/
|
||||||
|
function toggleApiKeyVisibility() {
|
||||||
|
const input = document.getElementById('apiKeyInput');
|
||||||
|
if (!input) return;
|
||||||
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
|
}
|
||||||
|
|||||||
@@ -356,7 +356,9 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
// Update panel elements
|
// Update panel elements
|
||||||
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
||||||
document.getElementById('btDetailAddress').textContent = device.address;
|
document.getElementById('btDetailAddress').textContent = isUuidAddress(device)
|
||||||
|
? 'CB: ' + device.address
|
||||||
|
: device.address;
|
||||||
|
|
||||||
// RSSI
|
// RSSI
|
||||||
const rssiEl = document.getElementById('btDetailRssi');
|
const rssiEl = document.getElementById('btDetailRssi');
|
||||||
@@ -367,6 +369,9 @@ const BluetoothMode = (function() {
|
|||||||
const badgesEl = document.getElementById('btDetailBadges');
|
const badgesEl = document.getElementById('btDetailBadges');
|
||||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
||||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||||
|
if (device.seen_before) {
|
||||||
|
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Tracker badge
|
// Tracker badge
|
||||||
if (device.is_tracker) {
|
if (device.is_tracker) {
|
||||||
@@ -455,6 +460,98 @@ const BluetoothMode = (function() {
|
|||||||
? new Date(device.last_seen).toLocaleTimeString()
|
? new Date(device.last_seen).toLocaleTimeString()
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
|
// New stat cells
|
||||||
|
document.getElementById('btDetailTxPower').textContent = device.tx_power != null
|
||||||
|
? device.tx_power + ' dBm' : '--';
|
||||||
|
document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null
|
||||||
|
? device.seen_rate.toFixed(1) + '/min' : '--';
|
||||||
|
|
||||||
|
// Stability from variance
|
||||||
|
const stabilityEl = document.getElementById('btDetailStability');
|
||||||
|
if (device.rssi_variance != null) {
|
||||||
|
let stabLabel, stabColor;
|
||||||
|
if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; }
|
||||||
|
else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; }
|
||||||
|
else { stabLabel = 'Unstable'; stabColor = '#ef4444'; }
|
||||||
|
stabilityEl.textContent = stabLabel;
|
||||||
|
stabilityEl.style.color = stabColor;
|
||||||
|
} else {
|
||||||
|
stabilityEl.textContent = '--';
|
||||||
|
stabilityEl.style.color = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance with confidence
|
||||||
|
const distEl = document.getElementById('btDetailDistance');
|
||||||
|
if (device.estimated_distance_m != null) {
|
||||||
|
const confPct = Math.round((device.distance_confidence || 0) * 100);
|
||||||
|
distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%';
|
||||||
|
} else {
|
||||||
|
distEl.textContent = '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appearance badge
|
||||||
|
if (device.appearance_name) {
|
||||||
|
badgesHtml += '<span class="bt-detail-badge flag">' + escapeHtml(device.appearance_name) + '</span>';
|
||||||
|
badgesEl.innerHTML = badgesHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster indicator
|
||||||
|
const macClusterEl = document.getElementById('btDetailMacCluster');
|
||||||
|
if (macClusterEl) {
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
macClusterEl.textContent = device.mac_cluster_count + ' MACs';
|
||||||
|
macClusterEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
macClusterEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service data inspector
|
||||||
|
const inspectorEl = document.getElementById('btDetailServiceInspector');
|
||||||
|
const inspectorContent = document.getElementById('btInspectorContent');
|
||||||
|
if (inspectorEl && inspectorContent) {
|
||||||
|
const hasData = device.manufacturer_bytes || device.appearance != null ||
|
||||||
|
(device.service_data && Object.keys(device.service_data).length > 0);
|
||||||
|
if (hasData) {
|
||||||
|
inspectorEl.style.display = '';
|
||||||
|
let inspHtml = '';
|
||||||
|
if (device.appearance != null) {
|
||||||
|
const name = device.appearance_name || '';
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Appearance</span><span class="bt-inspector-value">0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.manufacturer_bytes) {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Mfr Data</span><span class="bt-inspector-value">' + escapeHtml(device.manufacturer_bytes) + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.service_data) {
|
||||||
|
Object.entries(device.service_data).forEach(([uuid, hex]) => {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">' + escapeHtml(uuid) + '</span><span class="bt-inspector-value">' + escapeHtml(hex) + '</span></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
inspectorContent.innerHTML = inspHtml;
|
||||||
|
} else {
|
||||||
|
inspectorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWatchlistButton(device);
|
||||||
|
|
||||||
|
// IRK
|
||||||
|
const irkContainer = document.getElementById('btDetailIrk');
|
||||||
|
if (irkContainer) {
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkContainer.style.display = 'block';
|
||||||
|
const irkVal = document.getElementById('btDetailIrkValue');
|
||||||
|
if (irkVal) {
|
||||||
|
const label = device.irk_source_name
|
||||||
|
? device.irk_source_name + ' — ' + device.irk_hex
|
||||||
|
: device.irk_hex;
|
||||||
|
irkVal.textContent = label;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
irkContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const servicesContainer = document.getElementById('btDetailServices');
|
const servicesContainer = document.getElementById('btDetailServices');
|
||||||
const servicesList = document.getElementById('btDetailServicesList');
|
const servicesList = document.getElementById('btDetailServicesList');
|
||||||
@@ -473,6 +570,22 @@ const BluetoothMode = (function() {
|
|||||||
highlightSelectedDevice(deviceId);
|
highlightSelectedDevice(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update watchlist button state
|
||||||
|
*/
|
||||||
|
function updateWatchlistButton(device) {
|
||||||
|
const btn = document.getElementById('btDetailWatchBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
if (typeof AlertCenter === 'undefined') {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.style.display = '';
|
||||||
|
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||||
|
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||||
|
btn.classList.toggle('active', watchlisted);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device selection
|
* Clear device selection
|
||||||
*/
|
*/
|
||||||
@@ -531,7 +644,7 @@ const BluetoothMode = (function() {
|
|||||||
if (!device) return;
|
if (!device) return;
|
||||||
|
|
||||||
navigator.clipboard.writeText(device.address).then(() => {
|
navigator.clipboard.writeText(device.address).then(() => {
|
||||||
const btn = document.querySelector('.bt-detail-btn');
|
const btn = document.getElementById('btDetailCopyBtn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
const originalText = btn.textContent;
|
const originalText = btn.textContent;
|
||||||
btn.textContent = 'Copied!';
|
btn.textContent = 'Copied!';
|
||||||
@@ -544,6 +657,25 @@ const BluetoothMode = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle Bluetooth watchlist for selected device
|
||||||
|
*/
|
||||||
|
function toggleWatchlist() {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const device = devices.get(selectedDeviceId);
|
||||||
|
if (!device || typeof AlertCenter === 'undefined') return;
|
||||||
|
|
||||||
|
if (AlertCenter.isWatchlisted(device.address)) {
|
||||||
|
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||||
|
showInfo('Removed from watchlist');
|
||||||
|
} else {
|
||||||
|
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||||
|
showInfo('Added to watchlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => updateWatchlistButton(device), 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a device - opens modal with details
|
* Select a device - opens modal with details
|
||||||
*/
|
*/
|
||||||
@@ -560,9 +692,25 @@ const BluetoothMode = (function() {
|
|||||||
if (parts.length === 6) {
|
if (parts.length === 6) {
|
||||||
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
||||||
}
|
}
|
||||||
|
// CoreBluetooth UUID format (8-4-4-4-12)
|
||||||
|
if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) {
|
||||||
|
return address.substring(0, 8) + '...';
|
||||||
|
}
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUuidAddress(device) {
|
||||||
|
return device.address_type === 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(device) {
|
||||||
|
if (!device || !device.address) return '--';
|
||||||
|
if (isUuidAddress(device)) {
|
||||||
|
return device.address.substring(0, 8) + '-...' + device.address.slice(-4);
|
||||||
|
}
|
||||||
|
return device.address;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check system capabilities
|
* Check system capabilities
|
||||||
*/
|
*/
|
||||||
@@ -620,6 +768,12 @@ const BluetoothMode = (function() {
|
|||||||
hideCapabilityWarning();
|
hideCapabilityWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide Ubertooth option based on capabilities
|
||||||
|
const ubertoothOption = document.getElementById('btScanModeUbertooth');
|
||||||
|
if (ubertoothOption) {
|
||||||
|
ubertoothOption.style.display = data.has_ubertooth ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (scanModeSelect && data.preferred_backend) {
|
if (scanModeSelect && data.preferred_backend) {
|
||||||
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
||||||
if (option) option.selected = true;
|
if (option) option.selected = true;
|
||||||
@@ -1045,7 +1199,7 @@ const BluetoothMode = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
||||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' +
|
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
|
||||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
evidenceHtml +
|
evidenceHtml +
|
||||||
@@ -1094,6 +1248,7 @@ const BluetoothMode = (function() {
|
|||||||
const trackerConfidence = device.tracker_confidence;
|
const trackerConfidence = device.tracker_confidence;
|
||||||
const riskScore = device.risk_score || 0;
|
const riskScore = device.risk_score || 0;
|
||||||
const agentName = device._agent || 'Local';
|
const agentName = device._agent || 'Local';
|
||||||
|
const seenBefore = device.seen_before === true;
|
||||||
|
|
||||||
// Calculate RSSI bar width (0-100%)
|
// Calculate RSSI bar width (0-100%)
|
||||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||||
@@ -1101,7 +1256,7 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
const displayName = device.name || formatDeviceId(device.address);
|
const displayName = device.name || formatDeviceId(device.address);
|
||||||
const name = escapeHtml(displayName);
|
const name = escapeHtml(displayName);
|
||||||
const addr = escapeHtml(device.address || 'Unknown');
|
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||||
const seenCount = device.seen_count || 0;
|
const seenCount = device.seen_count || 0;
|
||||||
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
||||||
@@ -1126,6 +1281,12 @@ const BluetoothMode = (function() {
|
|||||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRK badge - show if paired IRK is available
|
||||||
|
let irkBadge = '';
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkBadge = '<span class="bt-irk-badge">IRK</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Risk badge - show if risk score is significant
|
// Risk badge - show if risk score is significant
|
||||||
let riskBadge = '';
|
let riskBadge = '';
|
||||||
if (riskScore >= 0.3) {
|
if (riskScore >= 0.3) {
|
||||||
@@ -1143,10 +1304,38 @@ const BluetoothMode = (function() {
|
|||||||
statusDot = '<span class="bt-status-dot known"></span>';
|
statusDot = '<span class="bt-status-dot known"></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distance display
|
||||||
|
const distM = device.estimated_distance_m;
|
||||||
|
let distStr = '';
|
||||||
|
if (distM != null) {
|
||||||
|
distStr = '~' + distM.toFixed(1) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavioral flag badges
|
||||||
|
const hFlags = device.heuristic_flags || [];
|
||||||
|
let flagBadges = '';
|
||||||
|
if (device.is_persistent || hFlags.includes('persistent')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||||
|
}
|
||||||
|
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||||
|
}
|
||||||
|
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster badge
|
||||||
|
let clusterBadge = '';
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Build secondary info line
|
// Build secondary info line
|
||||||
let secondaryParts = [addr];
|
let secondaryParts = [addr];
|
||||||
if (mfr) secondaryParts.push(mfr);
|
if (mfr) secondaryParts.push(mfr);
|
||||||
|
if (distStr) secondaryParts.push(distStr);
|
||||||
secondaryParts.push('Seen ' + seenCount + '×');
|
secondaryParts.push('Seen ' + seenCount + '×');
|
||||||
|
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||||
// Add agent name if not Local
|
// Add agent name if not Local
|
||||||
if (agentName !== 'Local') {
|
if (agentName !== 'Local') {
|
||||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||||
@@ -1163,7 +1352,10 @@ const BluetoothMode = (function() {
|
|||||||
protoBadge +
|
protoBadge +
|
||||||
'<span class="bt-device-name">' + name + '</span>' +
|
'<span class="bt-device-name">' + name + '</span>' +
|
||||||
trackerBadge +
|
trackerBadge +
|
||||||
|
irkBadge +
|
||||||
riskBadge +
|
riskBadge +
|
||||||
|
flagBadges +
|
||||||
|
clusterBadge +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-right">' +
|
'<div class="bt-row-right">' +
|
||||||
'<div class="bt-rssi-container">' +
|
'<div class="bt-rssi-container">' +
|
||||||
@@ -1174,6 +1366,11 @@ const BluetoothMode = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
||||||
|
'<div class="bt-row-actions">' +
|
||||||
|
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' +
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
|
||||||
|
'Locate</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,6 +1450,18 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the service data inspector panel
|
||||||
|
*/
|
||||||
|
function toggleServiceInspector() {
|
||||||
|
const content = document.getElementById('btInspectorContent');
|
||||||
|
const arrow = document.getElementById('btInspectorArrow');
|
||||||
|
if (!content) return;
|
||||||
|
const open = content.style.display === 'none';
|
||||||
|
content.style.display = open ? '' : 'none';
|
||||||
|
if (arrow) arrow.classList.toggle('open', open);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Agent Handling
|
// Agent Handling
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -1349,6 +1558,48 @@ const BluetoothMode = (function() {
|
|||||||
updateRadar();
|
updateRadar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand off a device to BT Locate mode by device_id lookup.
|
||||||
|
*/
|
||||||
|
function locateById(deviceId) {
|
||||||
|
console.log('[BT] locateById called with:', deviceId);
|
||||||
|
const device = devices.get(deviceId);
|
||||||
|
if (!device) {
|
||||||
|
console.warn('[BT] Device not found in map for id:', deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doLocateHandoff(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand off the currently selected device to BT Locate mode.
|
||||||
|
*/
|
||||||
|
function locateDevice() {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const device = devices.get(selectedDeviceId);
|
||||||
|
if (!device) return;
|
||||||
|
doLocateHandoff(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLocateHandoff(device) {
|
||||||
|
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
||||||
|
if (typeof BtLocate !== 'undefined') {
|
||||||
|
BtLocate.handoff({
|
||||||
|
device_id: device.device_id,
|
||||||
|
mac_address: device.address,
|
||||||
|
address_type: device.address_type || null,
|
||||||
|
irk_hex: device.irk_hex || null,
|
||||||
|
known_name: device.name || null,
|
||||||
|
known_manufacturer: device.manufacturer_name || null,
|
||||||
|
last_known_rssi: device.rssi_current,
|
||||||
|
tx_power: device.tx_power || null,
|
||||||
|
appearance_name: device.appearance_name || null,
|
||||||
|
fingerprint_id: device.fingerprint_id || null,
|
||||||
|
mac_cluster_count: device.mac_cluster_count || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
@@ -1361,6 +1612,10 @@ const BluetoothMode = (function() {
|
|||||||
selectDevice,
|
selectDevice,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
copyAddress,
|
copyAddress,
|
||||||
|
toggleWatchlist,
|
||||||
|
locateDevice,
|
||||||
|
locateById,
|
||||||
|
toggleServiceInspector,
|
||||||
|
|
||||||
// Agent handling
|
// Agent handling
|
||||||
handleAgentChange,
|
handleAgentChange,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user