mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 00:03:33 -07:00
Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 257de37dfe | |||
| b4c47ed28b | |||
| cbcb8b02fa | |||
| 0a02325c0c | |||
| 9bfbd6231d | |||
| 191344f41b | |||
| dcb2488f47 | |||
| 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
|
||||
pgdata/
|
||||
pgdata.bak/
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Uncomment and set to use external storage for ADS-B history
|
||||
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||
@@ -54,3 +54,11 @@ intercept_agent_*.cfg
|
||||
# Temporary files
|
||||
/tmp/
|
||||
*.tmp
|
||||
|
||||
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
||||
data/weather_sat/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
+133
@@ -2,6 +2,139 @@
|
||||
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,11 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking.
|
||||
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Setup and Running
|
||||
### Docker (Primary)
|
||||
```bash
|
||||
# Build and run (basic profile)
|
||||
docker compose --profile basic up -d
|
||||
|
||||
# Build and run with ADS-B history (Postgres)
|
||||
docker compose --profile history up -d
|
||||
|
||||
# Rebuild after code changes
|
||||
docker compose --profile basic up -d --build
|
||||
|
||||
# Multi-arch build (amd64 + arm64 for RPi)
|
||||
./build-multiarch.sh
|
||||
```
|
||||
|
||||
### Local Setup (Alternative)
|
||||
```bash
|
||||
# Initial setup (installs dependencies and configures SDR tools)
|
||||
./setup.sh
|
||||
@@ -66,8 +81,12 @@ Each signal type has its own Flask blueprint:
|
||||
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||
- `satellite.py` - Pass prediction using TLE data
|
||||
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||
- `aprs.py` - Amateur packet radio via direwolf
|
||||
- `rtlamr.py` - Utility meter reading
|
||||
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||
|
||||
### Core Utilities (utils/)
|
||||
|
||||
@@ -91,6 +110,15 @@ Each signal type has its own Flask blueprint:
|
||||
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||
- `channel_analyzer.py` - Frequency band analysis
|
||||
|
||||
**Weather Satellite** (`utils/weather_sat.py`):
|
||||
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||
- Subprocess management with stdout parsing, image watcher via rglob
|
||||
- Pass prediction using skyfield TLE data
|
||||
|
||||
**SSTV Decoder** (`utils/sstv.py`):
|
||||
- ISS SSTV reception via slowrx with Doppler tracking
|
||||
- Singleton pattern, image gallery with timestamped filenames
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
||||
@@ -112,9 +140,25 @@ Each signal type has its own Flask blueprint:
|
||||
| acarsdec | ACARS messages | Output parsing |
|
||||
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||
| direwolf | APRS | TNC modem for packet radio |
|
||||
|
||||
### Frontend Structure
|
||||
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
|
||||
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||
- Data persisted via `./data:/app/data` volume mount
|
||||
|
||||
### Configuration
|
||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix
|
||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
||||
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||
|
||||
## Testing Notes
|
||||
|
||||
+103
-1
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||
|
||||
# Install system dependencies for SDR tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# RTL-SDR tools
|
||||
@@ -21,6 +24,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
multimon-ng \
|
||||
# Audio tools for Listening Post
|
||||
ffmpeg \
|
||||
# SSTV decoder runtime libs
|
||||
libsndfile1 \
|
||||
# SatDump runtime libs (weather satellite decoding)
|
||||
libpng16-16 \
|
||||
libtiff6 \
|
||||
libjemalloc2 \
|
||||
libvolk-bin \
|
||||
libnng1 \
|
||||
libzstd1 \
|
||||
# WiFi tools (aircrack-ng suite)
|
||||
aircrack-ng \
|
||||
iw \
|
||||
@@ -41,6 +53,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
soapysdr-module-rtlsdr \
|
||||
soapysdr-module-hackrf \
|
||||
soapysdr-module-lms7 \
|
||||
soapysdr-module-airspy \
|
||||
airspy \
|
||||
limesuite \
|
||||
hackrf \
|
||||
# Utilities
|
||||
@@ -56,13 +70,30 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
# GTK is required for slowrx (SSTV decoder GUI dependency).
|
||||
# Note: slowrx is kept for backwards compatibility, but the pure Python
|
||||
# SSTV decoder in utils/sstv/ is now the primary implementation.
|
||||
# GTK can be removed if slowrx is deprecated in future releases.
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libfftw3-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
# Build dump1090
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||
@@ -109,7 +140,66 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& make \
|
||||
&& cp acarsdec /usr/bin/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
|
||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||
&& apt-get remove -y \
|
||||
build-essential \
|
||||
git \
|
||||
@@ -117,6 +207,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
@@ -124,6 +222,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -135,7 +237,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data
|
||||
RUN mkdir -p /app/data /app/data/weather_sat
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
|
||||
@@ -31,11 +31,17 @@ Support the developer of this open-source project
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
|
||||
- **Listening Post** - Frequency scanner with audio monitoring
|
||||
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump
|
||||
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
|
||||
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
@@ -55,15 +61,54 @@ cd intercept
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Docker (Alternative)
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker compose up -d
|
||||
docker compose --profile basic up -d --build
|
||||
```
|
||||
|
||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||
> **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)
|
||||
|
||||
@@ -74,6 +119,47 @@ The ADS-B history feature persists aircraft messages to Postgres for long-term a
|
||||
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.
|
||||
|
||||
### Open the Interface
|
||||
@@ -154,6 +240,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[SatDump](https://github.com/SatDump/SatDump) |
|
||||
[Celestrak](https://celestrak.org/) |
|
||||
[Priyom.org](https://priyom.org/)
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-11_fae1348c",
|
||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
||||
"version": "2026-02-01_ba81b697",
|
||||
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||
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.process import cleanup_stale_processes
|
||||
from utils.sdr import SDRFactory
|
||||
@@ -38,6 +38,7 @@ from utils.constants import (
|
||||
MAX_BT_DEVICE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
import logging
|
||||
@@ -104,7 +105,7 @@ def inject_offline_settings():
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
}
|
||||
@@ -171,10 +172,21 @@ dsc_rtl_process = None
|
||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dsc_lock = threading.Lock()
|
||||
|
||||
# DMR / Digital Voice
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_lock = threading.Lock()
|
||||
|
||||
# TSCM (Technical Surveillance Countermeasures)
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
|
||||
# Deauth Attack Detection
|
||||
deauth_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
deauth_detector_lock = threading.Lock()
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE DICTIONARIES
|
||||
# ============================================
|
||||
@@ -204,6 +216,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_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_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||
|
||||
@@ -215,6 +230,82 @@ cleanup_manager.register(bt_beacons)
|
||||
cleanup_manager.register(adsb_aircraft)
|
||||
cleanup_manager.register(ais_vessels)
|
||||
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: {sdr_type: mode_name}
|
||||
sdr_device_registry: dict[int, dict[str, str]] = {}
|
||||
sdr_device_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
|
||||
"""Claim an SDR device for a mode.
|
||||
|
||||
Checks the in-app registry first, then probes the USB device to
|
||||
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')
|
||||
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
|
||||
|
||||
Returns:
|
||||
Error message if device is in use, None if successfully claimed
|
||||
"""
|
||||
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
|
||||
|
||||
with sdr_device_registry_lock:
|
||||
device_entry = sdr_device_registry.get(device_index, {})
|
||||
if sdr_type_key in device_entry:
|
||||
in_use_by = device_entry[sdr_type_key]
|
||||
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
|
||||
# Only relevant for RTL-SDR devices
|
||||
if sdr_type_key == 'rtlsdr':
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
|
||||
if device_index not in sdr_device_registry:
|
||||
sdr_device_registry[device_index] = {}
|
||||
sdr_device_registry[device_index][sdr_type_key] = mode_name
|
||||
return None
|
||||
|
||||
|
||||
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
|
||||
"""Release an SDR device from the registry.
|
||||
|
||||
Args:
|
||||
device_index: The SDR device index to release
|
||||
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
|
||||
"""
|
||||
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
|
||||
with sdr_device_registry_lock:
|
||||
entry = sdr_device_registry.get(device_index)
|
||||
if not entry:
|
||||
return
|
||||
entry.pop(sdr_type_key, None)
|
||||
if not entry:
|
||||
sdr_device_registry.pop(device_index, None)
|
||||
|
||||
|
||||
def get_sdr_device_status() -> dict[int, dict[str, str]]:
|
||||
"""Get current SDR device allocations.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device indices to {sdr_type: mode_name}
|
||||
"""
|
||||
with sdr_device_registry_lock:
|
||||
return {idx: dict(modes) for idx, modes in sdr_device_registry.items()}
|
||||
|
||||
|
||||
# ============================================
|
||||
@@ -226,6 +317,14 @@ def require_login():
|
||||
# Routes that don't require login (to avoid infinite redirect loop)
|
||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||
|
||||
# Allow audio streaming endpoints without session auth
|
||||
if request.path.startswith('/listening/audio/'):
|
||||
return None
|
||||
|
||||
# Allow WebSocket upgrade requests (page load already required auth)
|
||||
if request.path.startswith('/ws/'):
|
||||
return None
|
||||
|
||||
# Controller API endpoints use API key auth, not session auth
|
||||
# Allow agent push/pull endpoints without session login
|
||||
if request.path.startswith('/controller/'):
|
||||
@@ -279,7 +378,16 @@ def index() -> str:
|
||||
'rtlamr': check_tool('rtlamr')
|
||||
}
|
||||
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')
|
||||
@@ -294,6 +402,25 @@ def get_devices() -> Response:
|
||||
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()
|
||||
sdr_type_key = device.sdr_type.value if hasattr(device.sdr_type, 'value') else str(device.sdr_type)
|
||||
sdr_type_key = str(sdr_type_key).lower()
|
||||
device_registry = registry.get(device.index, {})
|
||||
d['in_use'] = sdr_type_key in device_registry
|
||||
d['used_by'] = device_registry.get(sdr_type_key)
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/devices/debug')
|
||||
def get_devices_debug() -> Response:
|
||||
"""Get detailed SDR device detection diagnostics."""
|
||||
@@ -552,6 +679,7 @@ def health_check() -> Response:
|
||||
'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),
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
@@ -566,19 +694,23 @@ def health_check() -> Response:
|
||||
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder and WiFi processes."""
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
|
||||
global 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
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
from utils.bluetooth import reset_bluetooth_scanner
|
||||
|
||||
killed = []
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -622,6 +754,35 @@ def kill_all() -> Response:
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
|
||||
# Reset DMR state
|
||||
with dmr_lock:
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
|
||||
# Reset Bluetooth state (legacy)
|
||||
with bt_lock:
|
||||
if bt_process:
|
||||
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
|
||||
|
||||
# Clear SDR device registry
|
||||
with sdr_device_registry_lock:
|
||||
sdr_device_registry.clear()
|
||||
|
||||
return jsonify({'status': 'killed', 'processes': killed})
|
||||
|
||||
|
||||
@@ -707,6 +868,18 @@ def main() -> None:
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
|
||||
# Register database cleanup functions
|
||||
from utils.database import (
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||
|
||||
# Start automatic cleanup of stale data entries
|
||||
cleanup_manager.start()
|
||||
|
||||
@@ -714,6 +887,15 @@ def main() -> None:
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
import os
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
# Update TLE data in background thread (non-blocking)
|
||||
def update_tle_background():
|
||||
try:
|
||||
@@ -738,6 +920,22 @@ def main() -> None:
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
print("KiwiSDR audio proxy enabled")
|
||||
except ImportError as e:
|
||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
print("WebSocket waterfall streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket waterfall disabled: {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
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,69 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.12.0"
|
||||
VERSION = "2.15.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"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",
|
||||
"date": "January 2026",
|
||||
@@ -139,6 +198,7 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
||||
# ADS-B settings
|
||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||
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_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||
@@ -149,20 +209,39 @@ 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_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||
|
||||
# Observer location settings
|
||||
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
|
||||
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
|
||||
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
|
||||
|
||||
# Satellite settings
|
||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
# Weather satellite settings
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
|
||||
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
|
||||
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
|
||||
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
|
||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
|
||||
|
||||
# Update checking
|
||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||
|
||||
# Alerting
|
||||
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
||||
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
||||
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||
|
||||
# Admin credentials
|
||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure application logging."""
|
||||
logging.basicConfig(
|
||||
|
||||
+38
-11
@@ -1,27 +1,31 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker Compose configuration for easy deployment
|
||||
#
|
||||
# Basic usage:
|
||||
# docker compose up -d
|
||||
# Basic usage (build locally):
|
||||
# docker compose --profile basic up -d --build
|
||||
#
|
||||
# Basic usage (pre-built image from registry):
|
||||
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
||||
#
|
||||
# With ADS-B history (Postgres):
|
||||
# docker compose --profile history up -d
|
||||
|
||||
services:
|
||||
intercept:
|
||||
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
build: .
|
||||
container_name: intercept
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Privileged mode required for USB SDR device access
|
||||
# Alternatively, use device mapping (see below)
|
||||
privileged: true
|
||||
# USB device mapping (alternative to privileged mode)
|
||||
# devices:
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
# volumes:
|
||||
# Persist data directory
|
||||
# - ./data:/app/data
|
||||
# USB device mapping for all USB devices
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
# Persist decoded images and database across container rebuilds
|
||||
- ./data:/app/data
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
@@ -36,6 +40,13 @@ services:
|
||||
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||
# - INTERCEPT_ADSB_DB_USER=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: host
|
||||
restart: unless-stopped
|
||||
@@ -49,15 +60,23 @@ services:
|
||||
# ADS-B history with Postgres persistence
|
||||
# Enable with: docker compose --profile history up -d
|
||||
intercept-history:
|
||||
# Same image/build fallback pattern as above
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
build: .
|
||||
container_name: intercept
|
||||
container_name: intercept-history
|
||||
profiles:
|
||||
- history
|
||||
depends_on:
|
||||
- adsb_db
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Privileged mode required for USB SDR device access
|
||||
privileged: true
|
||||
# USB device mapping for all USB devices
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
@@ -68,6 +87,13 @@ services:
|
||||
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||
- INTERCEPT_ADSB_DB_USER=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
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||
@@ -86,7 +112,8 @@ services:
|
||||
- POSTGRES_USER=intercept
|
||||
- POSTGRES_PASSWORD=intercept
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
# Default local path (override with PGDATA_PATH for external storage)
|
||||
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
www.intercept-sigint.com
|
||||
@@ -0,0 +1,608 @@
|
||||
# iNTERCEPT UI Guide
|
||||
|
||||
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Design Tokens](#design-tokens)
|
||||
2. [Base Templates](#base-templates)
|
||||
3. [Navigation](#navigation)
|
||||
4. [Components](#components)
|
||||
5. [Adding a New Module Page](#adding-a-new-module-page)
|
||||
6. [Adding a New Dashboard](#adding-a-new-dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Design Tokens
|
||||
|
||||
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
|
||||
|
||||
### Colors
|
||||
|
||||
```css
|
||||
/* Backgrounds (layered depth) */
|
||||
--bg-primary: #0a0c10; /* Darkest - page background */
|
||||
--bg-secondary: #0f1218; /* Panels, sidebars */
|
||||
--bg-tertiary: #151a23; /* Cards, elevated elements */
|
||||
--bg-card: #121620; /* Card backgrounds */
|
||||
--bg-elevated: #1a202c; /* Hover states, modals */
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-cyan: #4a9eff; /* Primary action color */
|
||||
--accent-green: #22c55e; /* Success, online status */
|
||||
--accent-red: #ef4444; /* Error, danger, stop */
|
||||
--accent-orange: #f59e0b; /* Warning */
|
||||
--accent-amber: #d4a853; /* Secondary highlight */
|
||||
|
||||
/* Text Hierarchy */
|
||||
--text-primary: #e8eaed; /* Main content */
|
||||
--text-secondary: #9ca3af; /* Secondary content */
|
||||
--text-dim: #4b5563; /* Disabled, placeholder */
|
||||
--text-muted: #374151; /* Barely visible */
|
||||
|
||||
/* Status Colors */
|
||||
--status-online: #22c55e;
|
||||
--status-warning: #f59e0b;
|
||||
--status-error: #ef4444;
|
||||
--status-offline: #6b7280;
|
||||
```
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```css
|
||||
/* Font Families */
|
||||
--font-sans: 'Inter', -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 10px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 20px;
|
||||
--text-3xl: 24px;
|
||||
--text-4xl: 30px;
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
|
||||
```css
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
```
|
||||
|
||||
### Light Theme
|
||||
|
||||
The design system supports light/dark themes via `data-theme` attribute:
|
||||
|
||||
```html
|
||||
<html data-theme="dark"> <!-- or "light" -->
|
||||
```
|
||||
|
||||
Toggle with JavaScript:
|
||||
```javascript
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base Templates
|
||||
|
||||
### `templates/layout/base.html`
|
||||
|
||||
The main base template for standard pages. Use for pages with sidebar + content layout.
|
||||
|
||||
```html
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}My Page Title{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
{% set active_mode = 'mymode' %}
|
||||
{% include 'partials/nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<div class="app-sidebar">
|
||||
<!-- Sidebar content -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-container">
|
||||
<h1>Page Title</h1>
|
||||
<!-- Page content -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Page-specific JavaScript
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### `templates/layout/base_dashboard.html`
|
||||
|
||||
Extended base for full-screen dashboards (maps, visualizations).
|
||||
|
||||
```html
|
||||
{% extends 'layout/base_dashboard.html' %}
|
||||
|
||||
{% set active_mode = 'mydashboard' %}
|
||||
|
||||
{% block dashboard_title %}MY DASHBOARD{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block stats_strip %}
|
||||
<div class="stats-strip">
|
||||
<!-- Stats bar content -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="dashboard-map-container">
|
||||
<!-- Main visualization -->
|
||||
</div>
|
||||
<div class="dashboard-sidebar">
|
||||
<!-- Sidebar panels -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
### Including Navigation
|
||||
|
||||
```html
|
||||
{% set active_mode = 'pager' %}
|
||||
{% include 'partials/nav.html' %}
|
||||
```
|
||||
|
||||
### Valid `active_mode` Values
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `pager` | Pager decoding |
|
||||
| `sensor` | 433MHz sensors |
|
||||
| `rtlamr` | Utility meters |
|
||||
| `adsb` | Aircraft tracking |
|
||||
| `ais` | Vessel tracking |
|
||||
| `aprs` | Amateur radio |
|
||||
| `wifi` | WiFi scanning |
|
||||
| `bluetooth` | Bluetooth scanning |
|
||||
| `tscm` | Counter-surveillance |
|
||||
| `satellite` | Satellite tracking |
|
||||
| `sstv` | ISS SSTV |
|
||||
| `listening` | Listening post |
|
||||
| `spystations` | Spy stations |
|
||||
| `meshtastic` | Mesh networking |
|
||||
|
||||
### Navigation Groups
|
||||
|
||||
The navigation is organized into groups:
|
||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||
- **Wireless**: WiFi, Bluetooth
|
||||
- **Security**: TSCM
|
||||
- **Space**: Satellite, ISS SSTV
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Card / Panel
|
||||
|
||||
```html
|
||||
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
|
||||
<p>Panel content here</p>
|
||||
{% endcall %}
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```html
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>PANEL TITLE</span>
|
||||
<div class="panel-indicator active"></div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<p>Content here</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Empty State
|
||||
|
||||
```html
|
||||
{% include 'components/empty_state.html' with context %}
|
||||
{# Or with variables: #}
|
||||
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
|
||||
{% include 'components/empty_state.html' %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
### Loading State
|
||||
|
||||
```html
|
||||
{# Inline spinner #}
|
||||
{% include 'components/loading.html' %}
|
||||
|
||||
{# With text #}
|
||||
{% with text='Loading data...', size='lg' %}
|
||||
{% include 'components/loading.html' %}
|
||||
{% endwith %}
|
||||
|
||||
{# Full overlay #}
|
||||
{% with overlay=true, text='Please wait...' %}
|
||||
{% include 'components/loading.html' %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
### Status Badge
|
||||
|
||||
```html
|
||||
{% with status='online', text='Connected', id='connectionStatus' %}
|
||||
{% include 'components/status_badge.html' %}
|
||||
{% endwith %}
|
||||
```
|
||||
|
||||
Status values: `online`, `offline`, `warning`, `error`, `inactive`
|
||||
|
||||
### Buttons
|
||||
|
||||
```html
|
||||
<!-- Primary action -->
|
||||
<button class="btn btn-primary">Start Tracking</button>
|
||||
|
||||
<!-- Secondary action -->
|
||||
<button class="btn btn-secondary">Cancel</button>
|
||||
|
||||
<!-- Danger action -->
|
||||
<button class="btn btn-danger">Stop</button>
|
||||
|
||||
<!-- Ghost/subtle -->
|
||||
<button class="btn btn-ghost">Settings</button>
|
||||
|
||||
<!-- Sizes -->
|
||||
<button class="btn btn-primary btn-sm">Small</button>
|
||||
<button class="btn btn-primary btn-lg">Large</button>
|
||||
|
||||
<!-- Icon button -->
|
||||
<button class="btn btn-icon btn-secondary">
|
||||
<span class="icon">...</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### Badges
|
||||
|
||||
```html
|
||||
<span class="badge">Default</span>
|
||||
<span class="badge badge-primary">Primary</span>
|
||||
<span class="badge badge-success">Online</span>
|
||||
<span class="badge badge-warning">Warning</span>
|
||||
<span class="badge badge-danger">Error</span>
|
||||
```
|
||||
|
||||
### Form Groups
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<label for="frequency">Frequency (MHz)</label>
|
||||
<input type="text" id="frequency" value="153.350">
|
||||
<span class="form-help">Enter frequency in MHz</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gain">Gain</label>
|
||||
<select id="gain">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="30">30 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="form-check">
|
||||
<input type="checkbox" id="alerts">
|
||||
<span>Enable alerts</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
### Stats Strip
|
||||
|
||||
Used in dashboards for horizontal statistics display:
|
||||
|
||||
```html
|
||||
<div class="stats-strip">
|
||||
<div class="stats-strip-inner">
|
||||
<div class="strip-stat">
|
||||
<span class="strip-value" id="count">0</span>
|
||||
<span class="strip-label">COUNT</span>
|
||||
</div>
|
||||
<div class="strip-divider"></div>
|
||||
<div class="strip-status">
|
||||
<div class="status-dot active" id="statusDot"></div>
|
||||
<span id="statusText">TRACKING</span>
|
||||
</div>
|
||||
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Module Page
|
||||
|
||||
### 1. Create the Route
|
||||
|
||||
In `routes/mymodule.py`:
|
||||
|
||||
```python
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
|
||||
|
||||
@mymodule_bp.route('/dashboard')
|
||||
def dashboard():
|
||||
return render_template('mymodule_dashboard.html',
|
||||
offline_settings=get_offline_settings())
|
||||
```
|
||||
|
||||
### 2. Register the Blueprint
|
||||
|
||||
In `routes/__init__.py`:
|
||||
|
||||
```python
|
||||
from routes.mymodule import mymodule_bp
|
||||
app.register_blueprint(mymodule_bp)
|
||||
```
|
||||
|
||||
### 3. Create the Template
|
||||
|
||||
Option A: Simple page extending base.html
|
||||
```html
|
||||
{% extends 'layout/base.html' %}
|
||||
{% set active_mode = 'mymodule' %}
|
||||
|
||||
{% block title %}My Module{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
{% include 'partials/nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Your content -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Option B: Full-screen dashboard
|
||||
```html
|
||||
{% extends 'layout/base_dashboard.html' %}
|
||||
{% set active_mode = 'mymodule' %}
|
||||
|
||||
{% block dashboard_title %}MY MODULE{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<!-- Your dashboard content -->
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### 4. Add to Navigation
|
||||
|
||||
In `templates/partials/nav.html`, add your module to the appropriate group:
|
||||
|
||||
```html
|
||||
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||
onclick="switchMode('mymodule')">
|
||||
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||
<span class="nav-label">My Module</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Or if it's a dashboard link:
|
||||
```html
|
||||
<a href="/mymodule/dashboard"
|
||||
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||
style="text-decoration: none;">
|
||||
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||
<span class="nav-label">My Module</span>
|
||||
</a>
|
||||
```
|
||||
|
||||
### 5. Create Stylesheet
|
||||
|
||||
In `static/css/mymodule.css`:
|
||||
|
||||
```css
|
||||
/**
|
||||
* My Module Styles
|
||||
*/
|
||||
@import url('./core/variables.css');
|
||||
|
||||
/* Your styles using design tokens */
|
||||
.mymodule-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Dashboard
|
||||
|
||||
For full-screen dashboards like ADSB, AIS, or Satellite:
|
||||
|
||||
### 1. Create the Template
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MY DASHBOARD // iNTERCEPT</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||
|
||||
<!-- Design tokens (required) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
{% if offline_settings.fonts_source == 'local' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
<!-- External libraries if needed -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Dashboard styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Background effects -->
|
||||
<div class="radar-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<a href="/" style="color: inherit; text-decoration: none;">
|
||||
MY DASHBOARD
|
||||
<span>// iNTERCEPT</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
||||
<a href="/" class="back-link">Main Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Unified Navigation -->
|
||||
{% set active_mode = 'mydashboard' %}
|
||||
{% include 'partials/nav.html' %}
|
||||
|
||||
<!-- Stats Strip -->
|
||||
<div class="stats-strip">
|
||||
<!-- Stats content -->
|
||||
</div>
|
||||
|
||||
<!-- Main Dashboard Content -->
|
||||
<main class="dashboard">
|
||||
<!-- Your dashboard layout -->
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Dashboard JavaScript
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 2. Create the Stylesheet
|
||||
|
||||
```css
|
||||
/**
|
||||
* My Dashboard Styles
|
||||
*/
|
||||
@import url('./core/variables.css');
|
||||
|
||||
:root {
|
||||
/* Dashboard-specific aliases */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
--bg-card: var(--bg-tertiary);
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Your dashboard styles */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
|
||||
- Use design tokens for all colors, spacing, and typography
|
||||
- Include the nav partial on all pages for consistent navigation
|
||||
- Set `active_mode` before including the nav partial
|
||||
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
|
||||
- Support both light and dark themes
|
||||
- Test on mobile viewports
|
||||
|
||||
### DON'T
|
||||
|
||||
- Hardcode color values - use CSS variables
|
||||
- Create new color variations without adding to tokens
|
||||
- Duplicate navigation markup - use the partial
|
||||
- Skip the favicon and design tokens imports
|
||||
- Use inline styles for layout (use utility classes)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
templates/
|
||||
├── layout/
|
||||
│ ├── base.html # Standard page base
|
||||
│ └── base_dashboard.html # Dashboard page base
|
||||
├── partials/
|
||||
│ ├── nav.html # Unified navigation
|
||||
│ ├── page_header.html # Page title component
|
||||
│ └── settings-modal.html # Settings modal
|
||||
├── components/
|
||||
│ ├── card.html # Panel/card component
|
||||
│ ├── empty_state.html # Empty state placeholder
|
||||
│ ├── loading.html # Loading spinner
|
||||
│ ├── stats_strip.html # Stats bar component
|
||||
│ └── status_badge.html # Status indicator
|
||||
├── index.html # Main dashboard
|
||||
├── adsb_dashboard.html # Aircraft tracking
|
||||
├── ais_dashboard.html # Vessel tracking
|
||||
└── satellite_dashboard.html # Satellite tracking
|
||||
|
||||
static/css/
|
||||
├── core/
|
||||
│ ├── variables.css # Design tokens
|
||||
│ ├── base.css # Reset & typography
|
||||
│ ├── components.css # Component styles
|
||||
│ └── layout.css # Layout styles
|
||||
├── index.css # Main dashboard styles
|
||||
├── adsb_dashboard.css # Aircraft dashboard
|
||||
├── ais_dashboard.css # Vessel dashboard
|
||||
├── satellite_dashboard.css # Satellite dashboard
|
||||
└── responsive.css # Responsive breakpoints
|
||||
```
|
||||
+76
-43
@@ -61,55 +61,88 @@ INTERCEPT automatically detects known trackers:
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||
2. **Check Tools** - Ensure dump1090 or readsb is installed
|
||||
3. **Set Location** - Choose location source:
|
||||
- **Manual Entry** - Type coordinates directly
|
||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||
3. **Set Location** - Choose location source:
|
||||
- **Manual Entry** - Type coordinates directly
|
||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||
- **Shared Location** - By default, the observer location is shared across modules
|
||||
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||
6. **Click Aircraft** - Click markers for detailed information
|
||||
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
|
||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||
|
||||
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
|
||||
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
||||
|
||||
### Emergency Squawks
|
||||
|
||||
The system highlights aircraft transmitting emergency squawks:
|
||||
- **7500** - Hijack
|
||||
- **7600** - Radio failure
|
||||
- **7700** - General emergency
|
||||
|
||||
## ADS-B History (Optional)
|
||||
|
||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||
|
||||
### Enable History
|
||||
|
||||
Set the following environment variables (Docker recommended):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||
|
||||
### Docker Setup
|
||||
|
||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Using the History Dashboard
|
||||
|
||||
1. Open **/adsb/history**
|
||||
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||
3. View aircraft history and timelines
|
||||
4. Stop tracking when desired (session history is recorded)
|
||||
The system highlights aircraft transmitting emergency squawks:
|
||||
- **7500** - Hijack
|
||||
- **7600** - Radio failure
|
||||
- **7700** - General emergency
|
||||
|
||||
## ADS-B History (Optional)
|
||||
|
||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||
|
||||
### Enable History
|
||||
|
||||
Set the following environment variables (Docker recommended):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||
|
||||
**Local install example**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
### Docker Setup
|
||||
|
||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||
|
||||
```bash
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||
|
||||
```bash
|
||||
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||
```
|
||||
|
||||
### Using the History Dashboard
|
||||
|
||||
1. Open **/adsb/history**
|
||||
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||
3. View aircraft history and timelines
|
||||
4. Stop tracking when desired (session history is recorded)
|
||||
|
||||
## Satellite Mode
|
||||
|
||||
|
||||
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
|
||||
+588
-220
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.12.0"
|
||||
version = "2.15.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -33,6 +33,7 @@ dependencies = [
|
||||
"flask-limiter>=2.5.4",
|
||||
"bleak>=0.21.0",
|
||||
"flask-sock",
|
||||
"websocket-client>=1.6.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
@@ -52,6 +53,16 @@ dev = [
|
||||
"types-flask>=1.1.0",
|
||||
]
|
||||
|
||||
optionals = [
|
||||
"scipy>=1.10.0",
|
||||
"qrcode[pil]>=7.4",
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=9.0.0",
|
||||
"meshtastic>=2.0.0",
|
||||
"psycopg2-binary>=2.9.9",
|
||||
"scapy>=2.4.5",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
intercept = "intercept:main"
|
||||
|
||||
|
||||
+9
-1
@@ -13,16 +13,22 @@ bleak>=0.21.0
|
||||
# Satellite tracking (optional - only needed for satellite features)
|
||||
skyfield>=1.45
|
||||
|
||||
# DSC decoding (optional - only needed for VHF DSC maritime distress)
|
||||
# DSC decoding and SSTV decoding (DSP pipeline)
|
||||
scipy>=1.10.0
|
||||
numpy>=1.24.0
|
||||
|
||||
# SSTV image output (optional - needed for SSTV image decoding)
|
||||
Pillow>=9.0.0
|
||||
|
||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||
pyserial>=3.5
|
||||
|
||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||
meshtastic>=2.0.0
|
||||
|
||||
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||
scapy>=2.4.5
|
||||
|
||||
# QR code generation for Meshtastic channels (optional)
|
||||
qrcode[pil]>=7.4
|
||||
|
||||
@@ -32,4 +38,6 @@ qrcode[pil]>=7.4
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
websocket-client>=1.6.0
|
||||
|
||||
@@ -26,6 +26,12 @@ def register_blueprints(app):
|
||||
from .offline import offline_bp
|
||||
from .updater import updater_bp
|
||||
from .sstv import sstv_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .dmr import dmr_bp
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -51,6 +57,12 @@ def register_blueprints(app):
|
||||
app.register_blueprint(offline_bp) # Offline mode settings
|
||||
app.register_blueprint(updater_bp) # GitHub update checking
|
||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||
app.register_blueprint(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
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+57
-6
@@ -20,13 +20,15 @@ from flask import Blueprint, jsonify, request, Response
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
@@ -43,6 +45,9 @@ DEFAULT_ACARS_FREQUENCIES = [
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
acars_active_device: int | None = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
"""Find acarsdec binary."""
|
||||
@@ -141,9 +146,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global acars_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.acars_lock:
|
||||
app_module.acars_process = None
|
||||
# Release SDR device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
acars_active_device = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
@@ -175,7 +195,7 @@ def acars_status() -> Response:
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
global acars_message_count, acars_last_message_time, acars_active_device
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
@@ -202,6 +222,18 @@ def start_acars() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, '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
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
if isinstance(frequencies, str):
|
||||
@@ -282,7 +314,10 @@ def start_acars() -> Response:
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
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 = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
@@ -293,6 +328,7 @@ def start_acars() -> Response:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
app_module.acars_process = process
|
||||
register_process(process)
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
@@ -310,6 +346,10 @@ def start_acars() -> Response:
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
acars_active_device = None
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -317,6 +357,8 @@ def start_acars() -> Response:
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
global acars_active_device
|
||||
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
return jsonify({
|
||||
@@ -334,6 +376,11 @@ def stop_acars() -> Response:
|
||||
|
||||
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'})
|
||||
|
||||
|
||||
@@ -345,9 +392,13 @@ def stream_acars() -> Response:
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
|
||||
+146
-32
@@ -33,7 +33,12 @@ from config import (
|
||||
ADSB_DB_PASSWORD,
|
||||
ADSB_DB_PORT,
|
||||
ADSB_DB_USER,
|
||||
ADSB_AUTO_START,
|
||||
ADSB_HISTORY_ENABLED,
|
||||
ADSB_MLAT_ENABLED,
|
||||
ADSB_MLAT_SBS_HOST,
|
||||
ADSB_MLAT_SBS_PORT,
|
||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.validation import (
|
||||
@@ -41,6 +46,7 @@ from utils.validation import (
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
ADSB_SBS_PORT,
|
||||
@@ -68,7 +74,10 @@ adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
adsb_active_device = None # Track which device index is being used
|
||||
adsb_active_sdr_type = None
|
||||
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||
adsb_connected_sources: set[str] = set()
|
||||
_adsb_connection_lock = threading.Lock()
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
@@ -315,7 +324,29 @@ def check_dump1090_service():
|
||||
return None
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
def _reset_adsb_state() -> None:
|
||||
global adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
_sbs_error_logged = False
|
||||
with _adsb_connection_lock:
|
||||
adsb_connected_sources.clear()
|
||||
|
||||
|
||||
def _set_adsb_connected(source_key: str, connected: bool) -> None:
|
||||
global adsb_connected
|
||||
with _adsb_connection_lock:
|
||||
if connected:
|
||||
adsb_connected_sources.add(source_key)
|
||||
else:
|
||||
adsb_connected_sources.discard(source_key)
|
||||
adsb_connected = bool(adsb_connected_sources)
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
||||
|
||||
@@ -324,26 +355,23 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
source_label = source_tag or 'adsb'
|
||||
|
||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
_sbs_error_logged = False
|
||||
logger.info(f"SBS stream parser started ({source_label}), connecting to {host}:{port}")
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||
sock.connect((host, port))
|
||||
adsb_connected = True
|
||||
_set_adsb_connected(service_addr, True)
|
||||
_sbs_error_logged = False # Reset so we log next error
|
||||
logger.info("Connected to SBS stream")
|
||||
logger.info(f"Connected to SBS stream ({source_label})")
|
||||
|
||||
buffer = ""
|
||||
last_update = time.time()
|
||||
pending_updates = set()
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
local_lines_received = 0
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
@@ -361,13 +389,14 @@ def parse_sbs_stream(service_addr):
|
||||
continue
|
||||
|
||||
adsb_lines_received += 1
|
||||
local_lines_received += 1
|
||||
# Log first few lines for debugging
|
||||
if adsb_lines_received <= 3:
|
||||
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||
if local_lines_received <= 3:
|
||||
logger.info(f"SBS line ({source_label}) {local_lines_received}: {line[:100]}")
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
if local_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
@@ -418,6 +447,8 @@ def parse_sbs_stream(service_addr):
|
||||
try:
|
||||
aircraft['lat'] = float(parts[14])
|
||||
aircraft['lon'] = float(parts[15])
|
||||
if source_label:
|
||||
aircraft['position_source'] = source_label
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -491,18 +522,26 @@ def parse_sbs_stream(service_addr):
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
adsb_connected = False
|
||||
_set_adsb_connected(service_addr, False)
|
||||
except OSError as e:
|
||||
adsb_connected = False
|
||||
_set_adsb_connected(service_addr, False)
|
||||
if not _sbs_error_logged:
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
_sbs_error_logged = True
|
||||
time.sleep(SBS_RECONNECT_DELAY)
|
||||
|
||||
adsb_connected = False
|
||||
_set_adsb_connected(service_addr, False)
|
||||
logger.info("SBS stream parser stopped")
|
||||
|
||||
|
||||
def _start_mlat_stream(host: str, port: int) -> str:
|
||||
mlat_addr = f"{host}:{port}"
|
||||
logger.info(f"Connecting to MLAT SBS at {mlat_addr}")
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(mlat_addr, 'mlat'), daemon=True)
|
||||
thread.start()
|
||||
return mlat_addr
|
||||
|
||||
|
||||
@adsb_bp.route('/tools')
|
||||
def check_adsb_tools():
|
||||
"""Check for ADS-B decoding tools and hardware."""
|
||||
@@ -577,7 +616,7 @@ def adsb_session():
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
@@ -598,10 +637,22 @@ def start_adsb():
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
_reset_adsb_state()
|
||||
|
||||
# Check for remote SBS connection (e.g., remote dump1090)
|
||||
remote_sbs_host = data.get('remote_sbs_host')
|
||||
remote_sbs_port = data.get('remote_sbs_port', 30003)
|
||||
mlat_sbs_host = (data.get('mlat_sbs_host') or '').strip()
|
||||
mlat_sbs_port = data.get('mlat_sbs_port', ADSB_MLAT_SBS_PORT)
|
||||
if not mlat_sbs_host and ADSB_MLAT_ENABLED and ADSB_MLAT_SBS_HOST:
|
||||
mlat_sbs_host = ADSB_MLAT_SBS_HOST
|
||||
mlat_sbs_port = ADSB_MLAT_SBS_PORT
|
||||
if mlat_sbs_host:
|
||||
try:
|
||||
mlat_sbs_host = validate_rtl_tcp_host(mlat_sbs_host)
|
||||
mlat_sbs_port = validate_rtl_tcp_port(mlat_sbs_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if remote_sbs_host:
|
||||
# Validate and connect to remote dump1090 SBS output
|
||||
@@ -614,8 +665,10 @@ def start_adsb():
|
||||
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
||||
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr, 'adsb'), daemon=True)
|
||||
thread.start()
|
||||
if mlat_sbs_host:
|
||||
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type='remote',
|
||||
@@ -635,8 +688,10 @@ def start_adsb():
|
||||
if existing_service:
|
||||
logger.info(f"Found existing dump1090 service at {existing_service}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service, 'adsb'), daemon=True)
|
||||
thread.start()
|
||||
if mlat_sbs_host:
|
||||
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type='external',
|
||||
@@ -684,6 +739,16 @@ def start_adsb():
|
||||
app_module.adsb_process = None
|
||||
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', sdr_type.value)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
@@ -712,28 +777,59 @@ def start_adsb():
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
|
||||
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, sdr_type.value)
|
||||
stderr_output = ''
|
||||
if app_module.adsb_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
# Parse stderr to provide specific guidance
|
||||
error_type = 'START_FAILED'
|
||||
stderr_lower = stderr_output.lower()
|
||||
|
||||
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
|
||||
error_msg = 'SDR device is busy. Another process may be using it.'
|
||||
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
|
||||
error_type = 'DEVICE_BUSY'
|
||||
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
|
||||
error_msg = 'RTL-SDR device not found.'
|
||||
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
|
||||
error_type = 'DEVICE_NOT_FOUND'
|
||||
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
|
||||
error_msg = 'Kernel DVB-T driver is blocking the device.'
|
||||
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
|
||||
error_type = 'KERNEL_DRIVER'
|
||||
elif 'permission' in stderr_lower or 'access' in stderr_lower:
|
||||
error_msg = 'Permission denied accessing RTL-SDR device.'
|
||||
suggestion = 'Run Intercept with sudo, or add udev rules for RTL-SDR devices.'
|
||||
error_type = 'PERMISSION_DENIED'
|
||||
elif sdr_type == SDRType.RTL_SDR:
|
||||
error_msg = 'dump1090 failed to start.'
|
||||
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
|
||||
else:
|
||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.'
|
||||
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||
|
||||
full_msg = f'{error_msg} {suggestion}'
|
||||
if stderr_output and len(stderr_output) < 300:
|
||||
full_msg += f' (Details: {stderr_output})'
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': error_type,
|
||||
'message': full_msg
|
||||
})
|
||||
|
||||
adsb_using_service = True
|
||||
adsb_active_device = device # Track which device is being used
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
adsb_active_device = device # Track which device index is being used
|
||||
adsb_active_sdr_type = sdr_type.value
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}', 'adsb'), daemon=True)
|
||||
thread.start()
|
||||
if mlat_sbs_host:
|
||||
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
|
||||
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
@@ -750,13 +846,15 @@ def start_adsb():
|
||||
'session': session
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int, sdr_type.value)
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||
data = request.json or {}
|
||||
stop_source = data.get('source')
|
||||
stopped_by = request.remote_addr
|
||||
@@ -777,8 +875,15 @@ def stop_adsb():
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
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_active_sdr_type or 'rtlsdr')
|
||||
|
||||
adsb_using_service = False
|
||||
adsb_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
_reset_adsb_state()
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
@@ -796,6 +901,10 @@ def stream_adsb():
|
||||
try:
|
||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('adsb', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
@@ -812,7 +921,12 @@ def stream_adsb():
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_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_mlat_enabled=ADSB_MLAT_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@adsb_bp.route('/history')
|
||||
|
||||
+33
-2
@@ -15,9 +15,11 @@ from typing import Generator
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
import app as app_module
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.logging import get_logger
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
AIS_TCP_PORT,
|
||||
@@ -42,6 +44,7 @@ ais_connected = False
|
||||
ais_messages_received = 0
|
||||
ais_last_message_time = None
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type = None
|
||||
_ais_error_logged = True
|
||||
|
||||
# Common installation paths for AIS-catcher
|
||||
@@ -324,7 +327,7 @@ def ais_status():
|
||||
@ais_bp.route('/start', methods=['POST'])
|
||||
def start_ais():
|
||||
"""Start AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if ais_running:
|
||||
@@ -369,6 +372,16 @@ def start_ais():
|
||||
app_module.ais_process = None
|
||||
logger.info("Killed existing AIS process")
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type.value)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
# Build command using SDR abstraction
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
@@ -399,6 +412,8 @@ def start_ais():
|
||||
time.sleep(2.0)
|
||||
|
||||
if app_module.ais_process.poll() is not None:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int, sdr_type.value)
|
||||
stderr_output = ''
|
||||
if app_module.ais_process.stderr:
|
||||
try:
|
||||
@@ -412,6 +427,7 @@ def start_ais():
|
||||
|
||||
ais_running = True
|
||||
ais_active_device = device
|
||||
ais_active_sdr_type = sdr_type.value
|
||||
|
||||
# Start TCP parser thread
|
||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||
@@ -424,6 +440,8 @@ def start_ais():
|
||||
'port': tcp_port
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int, sdr_type.value)
|
||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -447,8 +465,14 @@ def stop_ais():
|
||||
pass
|
||||
app_module.ais_process = None
|
||||
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_active_sdr_type or 'rtlsdr')
|
||||
|
||||
ais_running = False
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type = None
|
||||
|
||||
app_module.ais_vessels.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
@@ -464,6 +488,10 @@ def stream_ais():
|
||||
try:
|
||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('ais', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
@@ -480,4 +508,7 @@ def stream_ais():
|
||||
@ais_bp.route('/dashboard')
|
||||
def 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
|
||||
+72
-5
@@ -13,7 +13,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from subprocess import DEVNULL, PIPE, STDOUT
|
||||
from subprocess import PIPE, STDOUT
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
@@ -22,6 +22,7 @@ import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
@@ -31,6 +32,9 @@ from utils.constants import (
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# Track which SDR device is being used
|
||||
aprs_active_device: int | None = None
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
'north_america': '144.390',
|
||||
@@ -49,6 +53,7 @@ aprs_packet_count = 0
|
||||
aprs_station_count = 0
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {} # callsign -> station data
|
||||
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
|
||||
|
||||
# Meter rate limiting
|
||||
_last_meter_time = 0.0
|
||||
@@ -1301,7 +1306,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
|
||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||
rtl_fm's stderr is sent to DEVNULL for the same reason.
|
||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
||||
|
||||
Outputs two types of messages to the queue:
|
||||
- type='aprs': Decoded APRS packets
|
||||
@@ -1367,6 +1372,13 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
@@ -1383,6 +1395,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global aprs_active_device
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1394,6 +1407,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
@@ -1441,6 +1458,7 @@ def get_stations() -> Response:
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
@@ -1477,6 +1495,16 @@ def start_aprs() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = device
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||
@@ -1552,15 +1580,25 @@ def start_aprs() -> Response:
|
||||
|
||||
try:
|
||||
# Start rtl_fm with stdout piped to decoder.
|
||||
# stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr).
|
||||
# stderr is captured via PIPE so errors are reported to the user.
|
||||
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=PIPE,
|
||||
stderr=DEVNULL,
|
||||
stderr=PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text:
|
||||
logger.debug(f"[RTL_FM] {err_text}")
|
||||
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||
rtl_stderr_thread.start()
|
||||
|
||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||
# Use text mode with line buffering for reliable line-by-line reading.
|
||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||
@@ -1582,13 +1620,25 @@ def start_aprs() -> Response:
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if rtl_process.poll() is not None:
|
||||
# rtl_fm exited early - something went wrong
|
||||
# rtl_fm exited early - capture stderr for diagnostics
|
||||
stderr_output = ''
|
||||
try:
|
||||
remaining = rtl_process.stderr.read()
|
||||
if remaining:
|
||||
stderr_output = remaining.decode('utf-8', errors='replace').strip()
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:200]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
if decoder_process.poll() is not None:
|
||||
@@ -1602,6 +1652,9 @@ def start_aprs() -> Response:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store references for status checks and cleanup
|
||||
@@ -1626,12 +1679,17 @@ def start_aprs() -> Response:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
|
||||
@@ -1660,6 +1718,11 @@ def stop_aprs() -> Response:
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -1673,6 +1736,10 @@ def stream_aprs() -> Response:
|
||||
try:
|
||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""WebSocket-based audio streaming for SDR."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import shutil
|
||||
import json
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
@@ -66,12 +67,6 @@ def kill_audio_processes():
|
||||
pass
|
||||
rtl_process = None
|
||||
|
||||
# Kill any orphaned processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
|
||||
except:
|
||||
pass
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
@@ -229,7 +224,11 @@ def init_audio_websocket(app: Flask):
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if "timed out" not in str(e).lower():
|
||||
msg = str(e).lower()
|
||||
if "connection closed" in msg:
|
||||
logger.info("WebSocket closed by client")
|
||||
break
|
||||
if "timed out" not in msg:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
|
||||
# Stream audio data if active
|
||||
@@ -253,4 +252,19 @@ def init_audio_websocket(app: Flask):
|
||||
finally:
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream.
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("WebSocket audio client disconnected")
|
||||
|
||||
+12
-7
@@ -18,10 +18,11 @@ from typing import Any, Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
@@ -561,9 +562,13 @@ def stream_bt():
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
|
||||
+177
-75
@@ -7,32 +7,40 @@ aggregation, and heuristics.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, session
|
||||
|
||||
from utils.bluetooth import (
|
||||
BluetoothScanner,
|
||||
BTDeviceAggregate,
|
||||
get_bluetooth_scanner,
|
||||
check_capabilities,
|
||||
RANGE_UNKNOWN,
|
||||
from utils.bluetooth import (
|
||||
BluetoothScanner,
|
||||
BTDeviceAggregate,
|
||||
get_bluetooth_scanner,
|
||||
check_capabilities,
|
||||
RANGE_UNKNOWN,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
get_tracker_engine,
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.sse import format_sse
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||
|
||||
# Blueprint
|
||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||
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
|
||||
@@ -164,13 +172,20 @@ def get_all_baselines() -> list[dict]:
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
|
||||
def save_observation_history(device: BTDeviceAggregate) -> None:
|
||||
"""Save device observation to history."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
|
||||
VALUES (?, ?, ?)
|
||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||
def save_observation_history(device: BTDeviceAggregate) -> None:
|
||||
"""Save device observation to history."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
|
||||
VALUES (?, ?, ?)
|
||||
''', (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}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -191,7 +206,7 @@ def get_capabilities():
|
||||
|
||||
|
||||
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
|
||||
def start_scan():
|
||||
def start_scan():
|
||||
"""
|
||||
Start Bluetooth scanning.
|
||||
|
||||
@@ -221,17 +236,42 @@ def start_scan():
|
||||
# Get scanner instance
|
||||
scanner = get_bluetooth_scanner(adapter_id)
|
||||
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
# Initialize database tables if needed
|
||||
init_bt_tables()
|
||||
|
||||
# Load active baseline if exists
|
||||
# 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 scanner._on_device_updated is None:
|
||||
scanner._on_device_updated = _handle_seen_before
|
||||
|
||||
# Ensure cache is initialized
|
||||
with _bt_seen_lock:
|
||||
if not _bt_seen_cache:
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
# Refresh seen-before cache and reset session set for a new scan
|
||||
with _bt_seen_lock:
|
||||
_bt_seen_cache.clear()
|
||||
_bt_seen_cache.update(load_seen_device_ids())
|
||||
_bt_session_seen.clear()
|
||||
|
||||
# Load active baseline if exists
|
||||
baseline_id = get_active_baseline_id()
|
||||
if baseline_id:
|
||||
device_ids = get_baseline_device_ids(baseline_id)
|
||||
@@ -856,11 +896,15 @@ def stream_events():
|
||||
else:
|
||||
return event_type, event
|
||||
|
||||
def event_generator() -> Generator[str, None, None]:
|
||||
"""Generate SSE events from scanner."""
|
||||
for event in scanner.stream_events(timeout=1.0):
|
||||
event_name, event_data = map_event_type(event)
|
||||
yield format_sse(event_data, event=event_name)
|
||||
def event_generator() -> Generator[str, None, None]:
|
||||
"""Generate SSE events from scanner."""
|
||||
for event in scanner.stream_events(timeout=1.0):
|
||||
event_name, event_data = map_event_type(event)
|
||||
try:
|
||||
process_event('bluetooth', event_data, event_name)
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event_data, event=event_name)
|
||||
|
||||
return Response(
|
||||
event_generator(),
|
||||
@@ -944,23 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
devices = scanner.get_devices()
|
||||
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
|
||||
|
||||
# Convert to TSCM format with tracker detection data
|
||||
tscm_devices = []
|
||||
for device in devices:
|
||||
device_data = {
|
||||
'mac': device.address,
|
||||
'address_type': device.address_type,
|
||||
'device_key': device.device_key,
|
||||
'name': device.name or 'Unknown',
|
||||
'rssi': device.rssi_current or -100,
|
||||
'rssi_median': device.rssi_median,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'type': _classify_device_type(device),
|
||||
'manufacturer': device.manufacturer_name,
|
||||
'manufacturer_id': device.manufacturer_id,
|
||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||
'protocol': device.protocol,
|
||||
'first_seen': device.first_seen.isoformat(),
|
||||
# Convert to TSCM format with tracker detection data
|
||||
tscm_devices = []
|
||||
for device in devices:
|
||||
manufacturer_name = device.manufacturer_name
|
||||
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_name = oui_vendor
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
device_data = {
|
||||
'mac': device.address,
|
||||
'address_type': device.address_type,
|
||||
'device_key': device.device_key,
|
||||
'name': device.name or 'Unknown',
|
||||
'rssi': device.rssi_current or -100,
|
||||
'rssi_median': device.rssi_median,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'type': _classify_device_type(device),
|
||||
'manufacturer': manufacturer_name,
|
||||
'manufacturer_id': device.manufacturer_id,
|
||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||
'protocol': device.protocol,
|
||||
'first_seen': device.first_seen.isoformat(),
|
||||
'last_seen': device.last_seen.isoformat(),
|
||||
'seen_count': device.seen_count,
|
||||
'range_band': device.range_band,
|
||||
@@ -1174,14 +1229,38 @@ def get_device_timeseries(device_key: str):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
"""Classify device type from available data."""
|
||||
name_lower = (device.name or '').lower()
|
||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||
|
||||
# Check by name patterns
|
||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||
return 'audio'
|
||||
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
"""Classify device type from available data."""
|
||||
name_lower = (device.name or '').lower()
|
||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||
service_uuids = device.service_uuids or []
|
||||
|
||||
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
||||
if device.address and not device.is_randomized_mac:
|
||||
try:
|
||||
from data.oui import get_manufacturer
|
||||
oui_vendor = get_manufacturer(device.address)
|
||||
if oui_vendor and oui_vendor != 'Unknown':
|
||||
manufacturer_lower = oui_vendor.lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def normalize_uuid(uuid: str) -> str:
|
||||
if not uuid:
|
||||
return ''
|
||||
value = str(uuid).lower().strip()
|
||||
if value.startswith('0x'):
|
||||
value = value[2:]
|
||||
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
||||
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
||||
return value[4:8]
|
||||
if len(value) == 4:
|
||||
return value
|
||||
return value
|
||||
|
||||
# Check by name patterns
|
||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||
return 'audio'
|
||||
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
|
||||
return 'wearable'
|
||||
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
|
||||
@@ -1190,18 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||
return 'computer'
|
||||
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
|
||||
return 'peripheral'
|
||||
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
|
||||
return 'tracker'
|
||||
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
|
||||
return 'speaker'
|
||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||
return 'media'
|
||||
|
||||
# Check by manufacturer
|
||||
if 'apple' in manufacturer_lower:
|
||||
return 'apple_device'
|
||||
if 'samsung' in manufacturer_lower:
|
||||
return 'samsung_device'
|
||||
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
|
||||
return 'tracker'
|
||||
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
|
||||
return 'speaker'
|
||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||
return 'media'
|
||||
|
||||
# Tracker signals (metadata or Find My service)
|
||||
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
||||
return 'tracker'
|
||||
|
||||
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
||||
if 'fd6f' in normalized_uuids:
|
||||
return 'tracker'
|
||||
|
||||
# Service UUIDs (GATT / classic)
|
||||
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
||||
wearable_uuids = {'180d', '1814', '1816'}
|
||||
hid_uuids = {'1812'}
|
||||
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
||||
|
||||
if normalized_uuids & audio_uuids:
|
||||
return 'audio'
|
||||
if normalized_uuids & hid_uuids:
|
||||
return 'peripheral'
|
||||
if normalized_uuids & wearable_uuids:
|
||||
return 'wearable'
|
||||
if normalized_uuids & beacon_uuids:
|
||||
return 'beacon'
|
||||
|
||||
# Check by manufacturer
|
||||
if 'apple' in manufacturer_lower:
|
||||
return 'apple_device'
|
||||
if 'samsung' in manufacturer_lower:
|
||||
return 'samsung_device'
|
||||
|
||||
# Check by class of device
|
||||
if device.major_class:
|
||||
|
||||
+124
-16
@@ -10,14 +10,16 @@ This blueprint provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
@@ -91,6 +93,17 @@ def register_agent():
|
||||
if not base_url:
|
||||
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
|
||||
existing = get_agent_by_name(name)
|
||||
if existing:
|
||||
@@ -128,9 +141,12 @@ def register_agent():
|
||||
update_agent(agent_id, update_last_seen=True)
|
||||
|
||||
agent = get_agent(agent_id)
|
||||
message = 'Agent registered successfully'
|
||||
if capabilities is None:
|
||||
message += ' (could not connect - agent may be offline)'
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Agent registered successfully',
|
||||
'message': message,
|
||||
'agent': agent
|
||||
}), 201
|
||||
|
||||
@@ -436,12 +452,12 @@ def proxy_mode_status(agent_id: int, mode: str):
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||
def proxy_mode_data(agent_id: int, mode: str):
|
||||
"""Get current data from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
@@ -459,7 +475,99 @@ def proxy_mode_data(agent_id: int, mode: str):
|
||||
'data': result
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||
def proxy_mode_stream(agent_id: int, mode: str):
|
||||
"""Proxy SSE stream from a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
query = request.query_string.decode('utf-8')
|
||||
url = f"{client.base_url}/{mode}/stream"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
headers = {'Accept': 'text/event-stream'}
|
||||
if agent.get('api_key'):
|
||||
headers['X-API-Key'] = agent['api_key']
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
continue
|
||||
yield chunk.decode('utf-8', errors='ignore')
|
||||
except Exception as e:
|
||||
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||
yield format_sse({
|
||||
'type': 'error',
|
||||
'message': str(e),
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
})
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||
def proxy_wifi_monitor(agent_id: int):
|
||||
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
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}'
|
||||
|
||||
+855
@@ -0,0 +1,855 @@
|
||||
"""DMR / P25 / Digital Voice decoding routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
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.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. Writes to an ffmpeg
|
||||
# stdin when a streaming client is connected, discards otherwise.
|
||||
# This prevents dsd-fme from blocking on stdout (which would also
|
||||
# freeze stderr / text data output).
|
||||
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint
|
||||
|
||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'p25p2', 'nxdn', 'dstar', 'provoice']
|
||||
VALID_DEMODS = ['nfm', 'fm']
|
||||
|
||||
# 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': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
|
||||
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!)
|
||||
'p25p2': ['-f2'], # P25 Phase 2
|
||||
'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
|
||||
'p25': ['-mc'], # C4FM (Phase 1)
|
||||
'p25p2': ['-mq'], # CQPSK (Phase 2)
|
||||
'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_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def _coerce_int(value) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_dsd_json(payload: dict, ts: str) -> dict | None:
|
||||
"""Parse JSON output lines from dsd-fme into events."""
|
||||
event_type = str(payload.get('type') or payload.get('event') or payload.get('msg') or payload.get('kind') or '').lower()
|
||||
nested = payload.get('data') if isinstance(payload.get('data'), dict) else {}
|
||||
|
||||
def first_of(keys):
|
||||
for obj in (payload, nested):
|
||||
for key in keys:
|
||||
if key in obj and obj[key] is not None:
|
||||
return obj[key]
|
||||
return None
|
||||
|
||||
talkgroup = _coerce_int(first_of([
|
||||
'tg', 'tgt', 'talkgroup', 'talk_group', 'tgid',
|
||||
'group', 'group_id', 'groupId', 'dst', 'dest',
|
||||
'destination', 'target'
|
||||
]))
|
||||
source = _coerce_int(first_of([
|
||||
'src', 'source', 'src_id', 'source_id', 'sourceId',
|
||||
'uid', 'unit', 'radio', 'rid', 'radio_id', 'radioId'
|
||||
]))
|
||||
slot = _coerce_int(first_of(['slot', 'timeslot', 'time_slot', 'ts']))
|
||||
nac = first_of(['nac'])
|
||||
protocol = first_of(['protocol', 'mode', 'system', 'sys', 'network'])
|
||||
|
||||
if talkgroup is not None and source is not None:
|
||||
event = {
|
||||
'type': 'call',
|
||||
'talkgroup': talkgroup,
|
||||
'source_id': source,
|
||||
'timestamp': ts,
|
||||
}
|
||||
if slot is not None:
|
||||
event['slot'] = slot
|
||||
if protocol:
|
||||
event['protocol'] = str(protocol)
|
||||
return event
|
||||
|
||||
if nac is not None:
|
||||
return {'type': 'nac', 'nac': str(nac), 'timestamp': ts}
|
||||
|
||||
if 'sync' in event_type:
|
||||
return {
|
||||
'type': 'sync',
|
||||
'protocol': str(protocol or event_type),
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
voice_flag = first_of(['voice', 'voice_frame', 'voiceFrame'])
|
||||
if 'voice' in event_type or voice_flag is True:
|
||||
event = {
|
||||
'type': 'voice',
|
||||
'detail': str(first_of(['detail', 'text']) or event_type or 'voice'),
|
||||
'timestamp': ts,
|
||||
}
|
||||
if slot is not None:
|
||||
event['slot'] = slot
|
||||
return event
|
||||
|
||||
if protocol:
|
||||
return {'type': 'sync', 'protocol': str(protocol), 'timestamp': ts}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
|
||||
# Frame-level error / OK indicators (useful for quality metrics)
|
||||
if re.search(r'\bDUID\s+ERR\b', line, re.IGNORECASE):
|
||||
return {
|
||||
'type': 'frame_error',
|
||||
'kind': 'duid',
|
||||
'detail': line[:200],
|
||||
'timestamp': ts,
|
||||
}
|
||||
if re.search(r'\bR-?S\s+ERR\b', line, re.IGNORECASE):
|
||||
return {
|
||||
'type': 'frame_error',
|
||||
'kind': 'rs',
|
||||
'detail': line[:200],
|
||||
'timestamp': ts,
|
||||
}
|
||||
if re.search(r'\bP25p2\b.*\b4V\b', line, re.IGNORECASE):
|
||||
return {
|
||||
'type': 'frame_ok',
|
||||
'kind': 'p25p2',
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# If dsd-fme is emitting JSON (via -J), parse it first.
|
||||
if line.startswith('{') and line.endswith('}'):
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
parsed = _parse_dsd_json(payload, ts)
|
||||
if parsed:
|
||||
return parsed
|
||||
return {
|
||||
'type': 'raw',
|
||||
'text': line[:200],
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# 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 _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 an audio streaming client is connected, forwards audio
|
||||
to its ffmpeg stdin with silence fill during voice gaps. When no
|
||||
client is connected, simply discards the data.
|
||||
"""
|
||||
try:
|
||||
while dmr_running:
|
||||
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
|
||||
if ready:
|
||||
data = os.read(dsd_stdout.fileno(), 4096)
|
||||
if not data:
|
||||
break
|
||||
sink = _active_ffmpeg_stdin
|
||||
if sink:
|
||||
try:
|
||||
sink.write(data)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass
|
||||
else:
|
||||
# No audio from decoder — feed silence if client connected
|
||||
sink = _active_ffmpeg_stdin
|
||||
if sink:
|
||||
try:
|
||||
sink.write(_SILENCE_CHUNK)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass
|
||||
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
|
||||
dmr_running = False
|
||||
# Capture exit info for diagnostics
|
||||
rc = dsd_process.poll()
|
||||
reason = 'stopped'
|
||||
detail = ''
|
||||
if rc is not None and rc != 0:
|
||||
reason = 'crashed'
|
||||
try:
|
||||
remaining = dsd_process.stderr.read(1024)
|
||||
if remaining:
|
||||
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||
# Cleanup decoder + demod processes
|
||||
for proc in [dsd_process, rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if proc:
|
||||
unregister_process(proc)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||
# Release SDR device
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
logger.info("DSD stream thread stopped")
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@dmr_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
dsd_path, _ = find_dsd()
|
||||
rtl_fm = find_rtl_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
return jsonify({
|
||||
'dsd': dsd_path is not None,
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'available': dsd_path is not None and rtl_fm is not None,
|
||||
'protocols': VALID_PROTOCOLS,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/start', methods=['POST'])
|
||||
def start_dmr() -> Response:
|
||||
"""Start digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
|
||||
dsd_path, is_fme = find_dsd()
|
||||
if not dsd_path:
|
||||
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
|
||||
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = 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))
|
||||
fine_tune = int(data.get('fineTune', 0) or 0)
|
||||
demod = str(data.get('demod', 'nfm')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||
if demod not in VALID_DEMODS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid demod. Use: {", ".join(VALID_DEMODS)}'}), 400
|
||||
if protocol == 'p25p2' and not is_fme:
|
||||
return jsonify({'status': 'error', 'message': 'P25 Phase 2 requires dsd-fme.'}), 400
|
||||
if abs(fine_tune) > 20000:
|
||||
return jsonify({'status': 'error', 'message': 'Fine tune offset too large (max +/- 20000 Hz).'}), 400
|
||||
|
||||
# Clear stale queue
|
||||
try:
|
||||
while True:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# 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:
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
dmr_active_device = device
|
||||
|
||||
freq_hz = int((frequency * 1e6) + fine_tune)
|
||||
|
||||
# Build rtl_fm command (48kHz sample rate for DSD).
|
||||
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
|
||||
# mid-frame, destroying DSD sync. The decoder handles silence
|
||||
# internally via its own frame-sync detection.
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', demod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', '48000',
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', '0',
|
||||
]
|
||||
if ppm != 0:
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# 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()
|
||||
|
||||
# Mark running before starting mux so it doesn't exit immediately.
|
||||
dmr_running = True
|
||||
|
||||
# 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 unregister all
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if proc:
|
||||
unregister_process(proc)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
# 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,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start DMR: {e}")
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@dmr_bp.route('/stop', methods=['POST'])
|
||||
def stop_dmr() -> Response:
|
||||
"""Stop digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
with dmr_lock:
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
|
||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if proc:
|
||||
unregister_process(proc)
|
||||
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@dmr_bp.route('/status')
|
||||
def dmr_status() -> Response:
|
||||
"""Get DMR decoder status."""
|
||||
return jsonify({
|
||||
'running': dmr_running,
|
||||
'device': dmr_active_device,
|
||||
'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).
|
||||
"""
|
||||
global _active_ffmpeg_stdin
|
||||
|
||||
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()
|
||||
|
||||
# Tell the mux thread to start writing to this ffmpeg
|
||||
_active_ffmpeg_stdin = audio_proc.stdin
|
||||
|
||||
def generate():
|
||||
global _active_ffmpeg_stdin
|
||||
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
|
||||
_active_ffmpeg_stdin = None
|
||||
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.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
logger = logging.getLogger('intercept.dsc')
|
||||
|
||||
@@ -47,6 +49,9 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
|
||||
# Module state (track if running independent of process state)
|
||||
dsc_running = False
|
||||
|
||||
# Track which device is being used
|
||||
dsc_active_device: int | None = None
|
||||
|
||||
|
||||
def _get_dsc_decoder_path() -> str | None:
|
||||
"""Get path to DSC decoder."""
|
||||
@@ -166,17 +171,34 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
'error': str(e)
|
||||
})
|
||||
finally:
|
||||
global dsc_active_device
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
decoder_process.wait()
|
||||
dsc_running = False
|
||||
# Cleanup both processes
|
||||
with app_module.dsc_lock:
|
||||
rtl_proc = app_module.dsc_rtl_process
|
||||
for proc in [rtl_proc, decoder_process]:
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
|
||||
with app_module.dsc_lock:
|
||||
app_module.dsc_process = None
|
||||
app_module.dsc_rtl_process = None
|
||||
# Release SDR device
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
dsc_active_device = None
|
||||
|
||||
|
||||
def _store_critical_alert(msg: dict) -> None:
|
||||
@@ -309,21 +331,18 @@ def start_decoding() -> Response:
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
# Check if device is in use by AIS
|
||||
try:
|
||||
from routes import ais as ais_module
|
||||
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
|
||||
# AIS is running - check if same device
|
||||
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': f'SDR device {device} is in use by AIS tracking',
|
||||
'suggestion': 'Use a different SDR device or stop AIS tracking first',
|
||||
'in_use_by': 'ais'
|
||||
}), 409
|
||||
except ImportError:
|
||||
pass
|
||||
# Check if device is available using centralized registry
|
||||
global dsc_active_device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
dsc_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
@@ -362,6 +381,7 @@ def start_decoding() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
# Start stderr monitor thread
|
||||
stderr_thread = threading.Thread(
|
||||
@@ -382,6 +402,7 @@ def start_decoding() -> Response:
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
register_process(decoder_process)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
@@ -408,11 +429,37 @@ def start_decoding() -> Response:
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Kill orphaned rtl_fm process
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
dsc_active_device = None
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Tool not found: {e.filename}'
|
||||
}), 400
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
dsc_active_device = None
|
||||
logger.error(f"Failed to start DSC decoder: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -423,7 +470,7 @@ def start_decoding() -> Response:
|
||||
@dsc_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
"""Stop DSC decoder."""
|
||||
global dsc_running
|
||||
global dsc_running, dsc_active_device
|
||||
|
||||
with app_module.dsc_lock:
|
||||
if not app_module.dsc_process:
|
||||
@@ -460,6 +507,11 @@ def stop_decoding() -> Response:
|
||||
app_module.dsc_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'})
|
||||
|
||||
|
||||
@@ -474,6 +526,10 @@ def stream() -> Response:
|
||||
try:
|
||||
msg = app_module.dsc_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('dsc', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
|
||||
+1057
-108
File diff suppressed because it is too large
Load Diff
+49
-9
@@ -3,8 +3,9 @@
|
||||
Provides endpoints for connecting to Meshtastic devices, configuring
|
||||
channels with encryption keys, and streaming received messages.
|
||||
|
||||
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
|
||||
connected via USB/Serial.
|
||||
Supports multiple connection types:
|
||||
- USB/Serial: Physical device connected via USB
|
||||
- TCP: WiFi-enabled devices accessible via IP address
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -95,7 +96,7 @@ def get_status():
|
||||
Get Meshtastic connection status.
|
||||
|
||||
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():
|
||||
return jsonify({
|
||||
@@ -111,6 +112,7 @@ def get_status():
|
||||
'available': True,
|
||||
'running': False,
|
||||
'device': None,
|
||||
'connection_type': None,
|
||||
'node_info': None,
|
||||
})
|
||||
|
||||
@@ -120,6 +122,7 @@ def get_status():
|
||||
'available': True,
|
||||
'running': client.is_running,
|
||||
'device': client.device_path,
|
||||
'connection_type': client.connection_type,
|
||||
'error': client.error,
|
||||
'node_info': node_info.to_dict() if node_info else None,
|
||||
})
|
||||
@@ -131,13 +134,20 @@ def start_mesh():
|
||||
Start Meshtastic listener.
|
||||
|
||||
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):
|
||||
{
|
||||
"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:
|
||||
JSON with connection status.
|
||||
"""
|
||||
@@ -151,7 +161,8 @@ def start_mesh():
|
||||
if client and client.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'device': client.device_path
|
||||
'device': client.device_path,
|
||||
'connection_type': client.connection_type
|
||||
})
|
||||
|
||||
# Clear queue and history
|
||||
@@ -162,18 +173,46 @@ def start_mesh():
|
||||
break
|
||||
_recent_messages.clear()
|
||||
|
||||
# Get optional device path
|
||||
# Parse connection parameters
|
||||
data = request.get_json(silent=True) or {}
|
||||
connection_type = data.get('connection_type', 'serial').lower().strip()
|
||||
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:
|
||||
device = str(device).strip()
|
||||
if not device:
|
||||
device = None
|
||||
|
||||
# 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:
|
||||
client = get_meshtastic_client()
|
||||
@@ -181,6 +220,7 @@ def start_mesh():
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'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,
|
||||
})
|
||||
else:
|
||||
|
||||
+4
-1
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'openstreetmap',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ ASSET_PATHS = {
|
||||
'static/vendor/leaflet/images/marker-shadow.png',
|
||||
'static/vendor/leaflet/images/layers.png',
|
||||
'static/vendor/leaflet/images/layers-2x.png'
|
||||
],
|
||||
'leaflet_heat': [
|
||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+190
-26
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -23,12 +25,17 @@ from utils.validation import (
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
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.dependencies import get_tool_path
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
pager_active_device: int | None = None
|
||||
pager_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
"""Parse multimon-ng output line."""
|
||||
@@ -102,6 +109,62 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
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:
|
||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||
try:
|
||||
@@ -143,18 +206,44 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
process.wait()
|
||||
# Signal relay thread to stop
|
||||
with app_module.process_lock:
|
||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||
if stop_relay:
|
||||
stop_relay.set()
|
||||
# Cleanup companion rtl_fm process and decoder
|
||||
with app_module.process_lock:
|
||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||
for proc in [rtl_proc, process]:
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.process_lock:
|
||||
app_module.current_process = None
|
||||
# Release SDR device
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
def start_decoding() -> Response:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
@@ -175,14 +264,42 @@ def start_decoding() -> Response:
|
||||
squelch = int(squelch)
|
||||
if not 0 <= squelch <= 1000:
|
||||
raise ValueError("Squelch must be between 0 and 1000")
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# 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', sdr_type.value)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
pager_active_device = device_int
|
||||
pager_active_sdr_type = sdr_type.value
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
if not isinstance(protocols, list):
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
protocols = valid_protocols
|
||||
@@ -206,18 +323,7 @@ def start_decoding() -> Response:
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
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
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
@@ -261,6 +367,7 @@ def start_decoding() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
@@ -279,18 +386,30 @@ def start_decoding() -> Response:
|
||||
|
||||
multimon_process = subprocess.Popen(
|
||||
multimon_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
register_process(multimon_process)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||||
stop_relay = threading.Event()
|
||||
relay = threading.Thread(
|
||||
target=audio_relay_thread,
|
||||
args=(rtl_process.stdout, multimon_process.stdin,
|
||||
app_module.output_queue, stop_relay),
|
||||
)
|
||||
relay.daemon = True
|
||||
relay.start()
|
||||
|
||||
app_module.current_process = multimon_process
|
||||
app_module.current_process._rtl_process = rtl_process
|
||||
app_module.current_process._master_fd = master_fd
|
||||
app_module.current_process._stop_relay = stop_relay
|
||||
app_module.current_process._relay_thread = relay
|
||||
|
||||
# Start output thread with PTY master fd
|
||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||
@@ -301,16 +420,50 @@ def start_decoding() -> Response:
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(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_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
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_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
def stop_decoding() -> Response:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
# Signal audio relay thread to stop
|
||||
if hasattr(app_module.current_process, '_stop_relay'):
|
||||
app_module.current_process._stop_relay.set()
|
||||
|
||||
# Kill rtl_fm process first
|
||||
if hasattr(app_module.current_process, '_rtl_process'):
|
||||
try:
|
||||
@@ -337,6 +490,13 @@ def stop_decoding() -> Response:
|
||||
app_module.current_process.kill()
|
||||
|
||||
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_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
@@ -397,6 +557,10 @@ def stream() -> Response:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+74
-10
@@ -18,7 +18,8 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
|
||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||
|
||||
@@ -26,6 +27,9 @@ rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||
rtl_tcp_process = None
|
||||
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:
|
||||
"""Stream rtlamr JSON output to queue."""
|
||||
@@ -58,15 +62,42 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
# Ensure rtlamr process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
# Kill companion rtl_tcp process
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
try:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_tcp_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(rtl_tcp_process)
|
||||
rtl_tcp_process = None
|
||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.rtlamr_lock:
|
||||
app_module.rtlamr_process = None
|
||||
# Release SDR device
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
rtlamr_active_device = None
|
||||
|
||||
|
||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||
def start_rtlamr() -> Response:
|
||||
global rtl_tcp_process
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
@@ -83,6 +114,18 @@ def start_rtlamr() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
rtlamr_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.rtlamr_queue.empty():
|
||||
try:
|
||||
@@ -118,7 +161,8 @@ def start_rtlamr() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
register_process(rtl_tcp_process)
|
||||
|
||||
# Wait a moment for rtl_tcp to start
|
||||
time.sleep(3)
|
||||
|
||||
@@ -126,6 +170,10 @@ def start_rtlamr() -> Response:
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||
# Release SDR device on rtl_tcp failure
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||
|
||||
# Build rtlamr command
|
||||
@@ -159,6 +207,7 @@ def start_rtlamr() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.rtlamr_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
||||
@@ -182,27 +231,33 @@ def start_rtlamr() -> Response:
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# If rtlamr fails, clean up rtl_tcp
|
||||
# If rtlamr fails, clean up rtl_tcp and release device
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
|
||||
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:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device)
|
||||
rtlamr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||
def stop_rtlamr() -> Response:
|
||||
global rtl_tcp_process
|
||||
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
app_module.rtlamr_process.terminate()
|
||||
@@ -211,7 +266,7 @@ def stop_rtlamr() -> Response:
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.rtlamr_process.kill()
|
||||
app_module.rtlamr_process = None
|
||||
|
||||
|
||||
# Also stop rtl_tcp
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
@@ -222,7 +277,12 @@ def stop_rtlamr() -> Response:
|
||||
rtl_tcp_process.kill()
|
||||
rtl_tcp_process = None
|
||||
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'})
|
||||
|
||||
|
||||
@@ -236,6 +296,10 @@ def stream_rtlamr() -> Response:
|
||||
try:
|
||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
|
||||
+41
-17
@@ -13,6 +13,8 @@ import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.logging import satellite_logger as logger
|
||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||
@@ -29,6 +31,23 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
||||
_tle_cache = dict(TLE_SATELLITES)
|
||||
|
||||
|
||||
def init_tle_auto_refresh():
|
||||
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
||||
import threading
|
||||
|
||||
def _auto_refresh_tle():
|
||||
try:
|
||||
updated = refresh_tle_data()
|
||||
if updated:
|
||||
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto TLE refresh failed: {e}")
|
||||
|
||||
# Start auto-refresh in background
|
||||
threading.Timer(2.0, _auto_refresh_tle).start()
|
||||
logger.info("TLE auto-refresh scheduled")
|
||||
|
||||
|
||||
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||
"""
|
||||
Fetch real-time ISS position from external APIs.
|
||||
@@ -40,30 +59,30 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
iss_alt = 420 # Default altitude in km
|
||||
source = None
|
||||
|
||||
# Try primary API: Open Notify
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('message') == 'success':
|
||||
iss_lat = float(data['iss_position']['latitude'])
|
||||
iss_lon = float(data['iss_position']['longitude'])
|
||||
source = 'open-notify'
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
iss_alt = float(data.get('altitude', 420))
|
||||
source = 'wheretheiss'
|
||||
except Exception as e:
|
||||
logger.debug(f"Open Notify API failed: {e}")
|
||||
logger.debug(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Where The ISS At
|
||||
# Try fallback API: Open Notify
|
||||
if iss_lat is None:
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
iss_alt = float(data.get('altitude', 420))
|
||||
source = 'wheretheiss'
|
||||
if data.get('message') == 'success':
|
||||
iss_lat = float(data['iss_position']['latitude'])
|
||||
iss_lon = float(data['iss_position']['longitude'])
|
||||
source = 'open-notify'
|
||||
except Exception as e:
|
||||
logger.debug(f"Where The ISS At API failed: {e}")
|
||||
logger.debug(f"Open Notify API failed: {e}")
|
||||
|
||||
if iss_lat is None:
|
||||
return None
|
||||
@@ -120,7 +139,10 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
return render_template('satellite_dashboard.html')
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
@@ -476,7 +498,8 @@ def update_tle():
|
||||
'updated': updated
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
logger.error(f"Error updating TLE data: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'TLE update failed'})
|
||||
|
||||
|
||||
@satellite_bp.route('/celestrak/<category>')
|
||||
@@ -530,4 +553,5 @@ def fetch_celestrak(category):
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
|
||||
|
||||
+106
-20
@@ -19,11 +19,16 @@ from utils.validation import (
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
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
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
sensor_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
@@ -41,6 +46,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
app_module.sensor_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rssi': rssi if rssi is not None else 0,
|
||||
'snr': snr if snr is not None else 0,
|
||||
'noise': noise if noise is not None else 0,
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -56,14 +76,39 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
@@ -79,6 +124,30 @@ def start_sensor() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# 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', sdr_type.value)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
sensor_active_sdr_type = sdr_type.value
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
@@ -86,18 +155,7 @@ def start_sensor() -> Response:
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# 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
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
@@ -126,12 +184,17 @@ def start_sensor() -> Response:
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
@@ -154,14 +217,26 @@ def start_sensor() -> Response:
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
@@ -170,6 +245,13 @@ def stop_sensor() -> Response:
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
@@ -185,6 +267,10 @@ def stream_sensor() -> Response:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
|
||||
+223
-40
@@ -13,13 +13,14 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_sstv_decoder,
|
||||
is_sstv_available,
|
||||
ISS_SSTV_FREQ,
|
||||
DecodeProgress,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.sstv')
|
||||
@@ -29,15 +30,18 @@ sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track which device is being used
|
||||
sstv_active_device: int | None = None
|
||||
|
||||
def _progress_callback(progress: DecodeProgress) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress/scope updates for SSE stream."""
|
||||
try:
|
||||
_sstv_queue.put_nowait(progress.to_dict())
|
||||
_sstv_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_sstv_queue.get_nowait()
|
||||
_sstv_queue.put_nowait(progress.to_dict())
|
||||
_sstv_queue.put_nowait(data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
@@ -53,13 +57,21 @@ def get_status():
|
||||
available = is_sstv_available()
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
return jsonify({
|
||||
result = {
|
||||
'available': available,
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'iss_frequency': ISS_SSTV_FREQ,
|
||||
'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'])
|
||||
@@ -70,16 +82,22 @@ def start_decoder():
|
||||
JSON body (optional):
|
||||
{
|
||||
"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:
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_sstv_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
|
||||
}), 400
|
||||
|
||||
decoder = get_sstv_decoder()
|
||||
@@ -87,7 +105,8 @@ def start_decoder():
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'frequency': ISS_SSTV_FREQ
|
||||
'frequency': ISS_SSTV_FREQ,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
})
|
||||
|
||||
# Clear queue
|
||||
@@ -101,6 +120,8 @@ def start_decoder():
|
||||
data = request.get_json(silent=True) or {}
|
||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||
device_index = data.get('device', 0)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
# Validate frequency
|
||||
try:
|
||||
@@ -116,17 +137,68 @@ def start_decoder():
|
||||
'message': 'Invalid frequency'
|
||||
}), 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
|
||||
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:
|
||||
return jsonify({
|
||||
sstv_active_device = device_int
|
||||
|
||||
result = {
|
||||
'status': 'started',
|
||||
'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:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder'
|
||||
@@ -141,11 +213,51 @@ def stop_decoder():
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
global sstv_active_device
|
||||
decoder = get_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
# Release device from registry
|
||||
if sstv_active_device is not None:
|
||||
app_module.release_sdr_device(sstv_active_device)
|
||||
sstv_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@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')
|
||||
def list_images():
|
||||
"""
|
||||
@@ -200,6 +312,73 @@ def get_image(filename: str):
|
||||
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')
|
||||
def stream_progress():
|
||||
"""
|
||||
@@ -221,6 +400,10 @@ def stream_progress():
|
||||
try:
|
||||
progress = _sstv_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sstv', progress, progress.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(progress)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
@@ -380,7 +563,32 @@ def iss_position():
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
|
||||
# Try primary API: Open Notify
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Open Notify
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
if response.status_code == 200:
|
||||
@@ -406,31 +614,6 @@ def iss_position():
|
||||
except Exception as e:
|
||||
logger.warning(f"Open Notify API failed: {e}")
|
||||
|
||||
# Try fallback API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Both APIs failed
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
|
||||
@@ -0,0 +1,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
|
||||
+3964
-3290
File diff suppressed because it is too large
Load Diff
+42
-2
@@ -2,14 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.updater import (
|
||||
check_for_updates,
|
||||
get_update_status,
|
||||
dismiss_update,
|
||||
get_update_status,
|
||||
perform_update,
|
||||
restart_application,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.routes.updater')
|
||||
@@ -137,3 +138,42 @@ def dismiss_notification() -> Response:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@updater_bp.route('/restart', methods=['POST'])
|
||||
def restart_app() -> Response:
|
||||
"""
|
||||
Restart the application.
|
||||
|
||||
This endpoint triggers a graceful restart of the application:
|
||||
1. Stops all running decoder processes
|
||||
2. Cleans up global state
|
||||
3. Replaces the current process with a fresh instance
|
||||
|
||||
The response may not be received by the client since the process
|
||||
is replaced immediately. Clients should poll /health until the
|
||||
server responds again.
|
||||
|
||||
Returns:
|
||||
JSON with restart status (may not be delivered)
|
||||
"""
|
||||
import threading
|
||||
|
||||
logger.info("Restart requested via API")
|
||||
|
||||
# Send response before restarting
|
||||
# Use a short delay to allow the response to be sent
|
||||
def delayed_restart():
|
||||
import time
|
||||
time.sleep(0.5) # Allow response to be sent
|
||||
restart_application()
|
||||
|
||||
# Start restart in a background thread so we can return a response
|
||||
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
|
||||
restart_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Application is restarting. Please wait...',
|
||||
'action': 'restart'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""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
|
||||
claimed_sdr_type = 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_sdr_type or 'rtlsdr')
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 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', sdr_type.value)
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
claimed_sdr_type = sdr_type.value
|
||||
|
||||
# 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, sdr_type.value)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 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, sdr_type.value)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 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_sdr_type or 'rtlsdr')
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 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, claimed_sdr_type or 'rtlsdr')
|
||||
claimed_sdr_type = None
|
||||
# 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")
|
||||
+260
-37
@@ -17,11 +17,12 @@ from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from data.oui import get_manufacturer
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
WIFI_TERMINATE_TIMEOUT,
|
||||
PMKID_TERMINATE_TIMEOUT,
|
||||
@@ -46,8 +47,33 @@ from utils.constants import (
|
||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||
|
||||
# PMKID process state
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||
"""Parse a channel list from string/list input."""
|
||||
if raw_channels in (None, '', []):
|
||||
return None
|
||||
|
||||
if isinstance(raw_channels, str):
|
||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||
elif isinstance(raw_channels, (list, tuple, set)):
|
||||
parts = list(raw_channels)
|
||||
else:
|
||||
parts = [raw_channels]
|
||||
|
||||
channels: list[int] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if part in (None, ''):
|
||||
continue
|
||||
ch = validate_wifi_channel(part)
|
||||
if ch not in seen:
|
||||
channels.append(ch)
|
||||
seen.add(ch)
|
||||
|
||||
return channels or None
|
||||
|
||||
|
||||
def detect_wifi_interfaces():
|
||||
@@ -607,8 +633,9 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
|
||||
data = request.json
|
||||
channel = data.get('channel')
|
||||
band = data.get('band', 'abg')
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
@@ -658,8 +685,17 @@ def start_wifi_scan():
|
||||
interface
|
||||
]
|
||||
|
||||
if channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
channel_list = None
|
||||
if channels:
|
||||
try:
|
||||
channel_list = _parse_channel_list(channels)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -851,32 +887,53 @@ def check_handshake_status():
|
||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
handshake_found = False
|
||||
handshake_found = False
|
||||
handshake_valid: bool | None = None
|
||||
handshake_checked = False
|
||||
handshake_reason: str | None = None
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
output_lower = output.lower()
|
||||
handshake_checked = True
|
||||
|
||||
if 'no valid wpa handshakes found' in output_lower:
|
||||
handshake_valid = False
|
||||
handshake_reason = 'No valid WPA handshake found'
|
||||
elif '0 handshake' in output_lower:
|
||||
handshake_valid = False
|
||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||
handshake_valid = True
|
||||
else:
|
||||
handshake_valid = False
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
if handshake_valid:
|
||||
handshake_found = True
|
||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||
app_module.wifi_handshakes.append(normalized_bssid)
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found,
|
||||
'handshake_valid': handshake_valid,
|
||||
'handshake_checked': handshake_checked,
|
||||
'handshake_reason': handshake_reason
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||
@@ -1084,9 +1141,13 @@ def stream_wifi():
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.wifi_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
msg = app_module.wifi_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
@@ -1240,10 +1301,32 @@ def v2_get_networks():
|
||||
|
||||
@wifi_bp.route('/v2/clients')
|
||||
def v2_get_clients():
|
||||
"""Get all discovered clients."""
|
||||
"""Get discovered clients with optional filtering."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
|
||||
# Filter by association status
|
||||
associated = request.args.get('associated')
|
||||
if associated == 'true':
|
||||
clients = [c for c in clients if c.is_associated]
|
||||
elif associated == 'false':
|
||||
clients = [c for c in clients if not c.is_associated]
|
||||
|
||||
# Filter by associated BSSID
|
||||
bssid = request.args.get('bssid')
|
||||
if bssid:
|
||||
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
|
||||
|
||||
# Filter by minimum RSSI
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'clients': [c.to_dict() for c in clients],
|
||||
'total': len(clients),
|
||||
@@ -1413,3 +1496,143 @@ def v2_clear_data():
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing data")
|
||||
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
|
||||
|
||||
+50
-28
@@ -16,14 +16,16 @@ from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,28 +87,44 @@ def start_deep_scan():
|
||||
|
||||
Requires monitor mode interface and root privileges.
|
||||
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
channels: Optional list or comma-separated channels to monitor
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = int(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'Invalid channels'}), 400
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = validate_wifi_channel(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
)
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
channels=channel_list,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
@@ -388,10 +406,14 @@ def event_stream():
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
yield format_sse(event)
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
try:
|
||||
process_event('wifi', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
|
||||
@@ -165,6 +165,7 @@ detect_dragonos() {
|
||||
# Required tool checks (with alternates)
|
||||
# ----------------------------
|
||||
missing_required=()
|
||||
missing_recommended=()
|
||||
|
||||
check_required() {
|
||||
local label="$1"; shift
|
||||
@@ -178,6 +179,18 @@ check_required() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_recommended() {
|
||||
local label="$1"; shift
|
||||
local desc="$1"; shift
|
||||
|
||||
if have_any "$@"; then
|
||||
ok "${label} - ${desc}"
|
||||
else
|
||||
warn "${label} - ${desc} (missing, recommended)"
|
||||
missing_recommended+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
check_optional() {
|
||||
local label="$1"; shift
|
||||
local desc="$1"; shift
|
||||
@@ -204,11 +217,15 @@ check_tools() {
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
|
||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
|
||||
echo
|
||||
info "Digital Voice:"
|
||||
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
|
||||
|
||||
echo
|
||||
info "Audio:"
|
||||
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||
@@ -385,6 +402,7 @@ install_rtlamr_from_source() {
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
install_multimon_ng_from_source_macos() {
|
||||
info "multimon-ng not available via Homebrew. Building from source..."
|
||||
|
||||
@@ -416,8 +434,277 @@ install_multimon_ng_from_source_macos() {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling acarsdec..."
|
||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
else
|
||||
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."
|
||||
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
|
||||
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 may take a while)..."
|
||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >/dev/null 2>&1 \
|
||||
&& make -j "$(nproc)" >/dev/null 2>&1; then
|
||||
$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
|
||||
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||
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 may take a while)..."
|
||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >/dev/null 2>&1 \
|
||||
&& make -j "$(sysctl -n hw.ncpu)" >/dev/null 2>&1; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
make install >/dev/null 2>&1
|
||||
else
|
||||
sudo make install >/dev/null 2>&1
|
||||
fi
|
||||
ok "SatDump installed successfully."
|
||||
else
|
||||
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_macos_packages() {
|
||||
TOTAL_STEPS=15
|
||||
TOTAL_STEPS=19
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -437,6 +724,22 @@ install_macos_packages() {
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
(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"
|
||||
brew_install ffmpeg
|
||||
|
||||
@@ -458,18 +761,39 @@ install_macos_packages() {
|
||||
fi
|
||||
|
||||
progress "Installing dump1090"
|
||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||
if ! cmd_exists dump1090; then
|
||||
(brew_install dump1090-mutability) || install_dump1090_from_source_macos || warn "dump1090 not available"
|
||||
else
|
||||
ok "dump1090 already installed"
|
||||
fi
|
||||
|
||||
progress "Installing acarsdec"
|
||||
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||
if ! cmd_exists acarsdec; then
|
||||
(brew_install acarsdec) || install_acarsdec_from_source_macos || warn "acarsdec not available"
|
||||
else
|
||||
ok "acarsdec already installed"
|
||||
fi
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
|
||||
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
||||
else
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
|
||||
progress "Installing SatDump (optional)"
|
||||
if ! cmd_exists satdump; then
|
||||
echo
|
||||
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||
if ask_yes_no "Do you want to install SatDump?"; then
|
||||
install_satdump_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"
|
||||
brew_install aircrack-ng
|
||||
|
||||
@@ -767,7 +1091,7 @@ install_debian_packages() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=20
|
||||
TOTAL_STEPS=26
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
@@ -811,19 +1135,9 @@ install_debian_packages() {
|
||||
|
||||
progress "RTL-SDR Blog drivers"
|
||||
if cmd_exists rtl_test; then
|
||||
info "RTL-SDR tools 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
|
||||
ok "RTL-SDR drivers already installed"
|
||||
else
|
||||
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
fi
|
||||
|
||||
@@ -833,6 +1147,22 @@ install_debian_packages() {
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
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"
|
||||
apt_install ffmpeg
|
||||
|
||||
@@ -918,15 +1248,29 @@ install_debian_packages() {
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
|
||||
progress "Installing SatDump (optional)"
|
||||
if ! cmd_exists satdump; then
|
||||
echo
|
||||
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||
if ask_yes_no "Do you want to install SatDump?"; then
|
||||
install_satdump_from_source_debian || warn "SatDump build failed. Weather satellite decoding will not be available."
|
||||
else
|
||||
warn "Skipping SatDump installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "SatDump already installed"
|
||||
fi
|
||||
|
||||
progress "Configuring udev rules"
|
||||
setup_udev_rules_debian
|
||||
|
||||
progress "Kernel driver configuration"
|
||||
echo
|
||||
if $IS_DRAGONOS; then
|
||||
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
|
||||
echo
|
||||
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
|
||||
echo "Blacklisting them allows rtl_sdr tools to access the device."
|
||||
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
|
||||
@@ -966,6 +1310,14 @@ final_summary_and_hard_fail() {
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${#missing_recommended[@]}" -gt 0 ]]; then
|
||||
echo
|
||||
warn "Missing RECOMMENDED tools (some features will not work):"
|
||||
for t in "${missing_recommended[@]}"; do echo " - $t"; done
|
||||
echo
|
||||
warn "Install these for full functionality"
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
@@ -1012,8 +1364,24 @@ main() {
|
||||
fi
|
||||
|
||||
install_python_deps
|
||||
|
||||
# Download leaflet-heat plugin (offline mode)
|
||||
if [ ! -f "static/vendor/leaflet-heat/leaflet-heat.js" ]; then
|
||||
info "Downloading leaflet-heat plugin..."
|
||||
mkdir -p static/vendor/leaflet-heat
|
||||
if curl -sL "https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js" \
|
||||
-o static/vendor/leaflet-heat/leaflet-heat.js; then
|
||||
ok "leaflet-heat plugin downloaded"
|
||||
else
|
||||
warn "Failed to download leaflet-heat plugin. Heatmap will use CDN."
|
||||
fi
|
||||
fi
|
||||
|
||||
final_summary_and_hard_fail
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
# Clear traps before exiting to prevent spurious errors during cleanup
|
||||
trap - ERR EXIT
|
||||
exit 0
|
||||
|
||||
+140
-56
@@ -5,27 +5,30 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-green: #22c55e;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--radar-cyan: #4a9eff;
|
||||
--radar-bg: #0f1218;
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: #4aa3ff;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-green: #38c180;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-red: #e25d5d;
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #4aa3ff;
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
@@ -40,9 +43,10 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -55,10 +59,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -71,7 +77,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Header - Mobile first */
|
||||
/* Header */
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
@@ -81,20 +87,31 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: 12px 20px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
@@ -126,14 +143,52 @@ body {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.agent-selector-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-select-sm {
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-status-dot.offline {
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 6px var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-compact .show-all-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -172,15 +227,15 @@ body {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: calc(100dvh - 115px);
|
||||
height: calc(100vh - 115px); /* Fallback */
|
||||
height: calc(100dvh - 160px);
|
||||
height: calc(100vh - 160px); /* Fallback */
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@@ -216,7 +271,7 @@ body {
|
||||
@media (min-width: 1024px) {
|
||||
.acars-sidebar {
|
||||
display: flex;
|
||||
max-height: calc(100dvh - 115px);
|
||||
max-height: calc(100dvh - 160px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,7 +679,7 @@ body {
|
||||
}
|
||||
|
||||
.telemetry-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -680,7 +735,7 @@ body {
|
||||
}
|
||||
|
||||
.aircraft-icao {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
@@ -700,7 +755,7 @@ body {
|
||||
}
|
||||
|
||||
.aircraft-detail-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -716,14 +771,31 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.03);
|
||||
border: 1px solid rgba(74, 158, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group > .control-group-items {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.controls-bar label {
|
||||
@@ -790,7 +862,7 @@ body {
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -801,7 +873,7 @@ body {
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -814,6 +886,24 @@ body {
|
||||
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 {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
@@ -861,7 +951,7 @@ body {
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -893,7 +983,7 @@ body {
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1005,7 +1095,7 @@ body {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
@@ -1039,7 +1129,7 @@ body {
|
||||
}
|
||||
|
||||
.airband-status {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 0 8px;
|
||||
color: var(--text-muted);
|
||||
@@ -1199,7 +1289,7 @@ body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
min-height: calc(100dvh - 115px);
|
||||
min-height: calc(100dvh - 160px);
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -1268,12 +1358,6 @@ body {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
/* Status bar - compact on mobile */
|
||||
.status-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Strip time smaller on mobile */
|
||||
.strip-time {
|
||||
font-size: 10px;
|
||||
@@ -1389,7 +1473,7 @@ body {
|
||||
}
|
||||
|
||||
.strip-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
@@ -1415,7 +1499,7 @@ body {
|
||||
}
|
||||
|
||||
.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;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
@@ -1527,7 +1611,7 @@ body {
|
||||
|
||||
.report-grid span:nth-child(even) {
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.report-highlights {
|
||||
@@ -1718,7 +1802,7 @@ body {
|
||||
}
|
||||
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
@@ -1766,7 +1850,7 @@ body {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||
white-space: nowrap;
|
||||
@@ -1920,7 +2004,7 @@ body {
|
||||
}
|
||||
|
||||
.squawk-code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 12px;
|
||||
|
||||
+38
-20
@@ -5,38 +5,42 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #141a24;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: rgba(74, 158, 255, 0.6);
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: rgba(74, 163, 255, 0.4);
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.radar-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -48,10 +52,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -72,6 +78,18 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
@@ -91,7 +109,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -268,7 +286,7 @@ body {
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
@@ -306,7 +324,7 @@ body {
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -347,7 +365,7 @@ body {
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.empty-row td,
|
||||
|
||||
+331
-343
@@ -1,343 +1,331 @@
|
||||
/*
|
||||
* Agents Management CSS
|
||||
* Styles for the remote agent management interface
|
||||
*/
|
||||
|
||||
/* CSS Variables (inherited from main theme) */
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #888;
|
||||
--border-color: #1a1a2e;
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-green: #00ff88;
|
||||
--accent-red: #ff3366;
|
||||
--accent-orange: #ff9f1c;
|
||||
}
|
||||
|
||||
/* Agent indicator in navigation */
|
||||
.agent-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-indicator:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.remote {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 6px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.multiple {
|
||||
background: var(--accent-orange);
|
||||
box-shadow: 0 0 6px var(--accent-orange);
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.agent-indicator-count {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Agent selector dropdown */
|
||||
.agent-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-header h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.agent-selector-manage {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agent-selector-manage:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.agent-selector-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-selector-item:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.agent-selector-item.selected {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-selector-item.local {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-status.online {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status.offline {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-url {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-check {
|
||||
color: var(--accent-green);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item.selected .agent-selector-item-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Agent badge in data displays */
|
||||
.agent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.agent-badge.local,
|
||||
.agent-badge.agent-local {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-badge.agent-remote {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* WiFi table agent column */
|
||||
.wifi-networks-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wifi-networks-table th.col-agent {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Bluetooth table agent column */
|
||||
.bt-devices-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Agent column in data tables */
|
||||
.data-table .agent-col {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Multi-agent stream indicator */
|
||||
.multi-agent-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.multi-agent-indicator.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.multi-agent-indicator-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* Agent connection status toast */
|
||||
.agent-toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
padding: 10px 15px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-toast.connected {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-toast.disconnected {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.agent-indicator {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.agents-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Agents Management CSS
|
||||
* Styles for the remote agent management interface
|
||||
* Inherits CSS variables from core/variables.css
|
||||
*/
|
||||
|
||||
/* Agent indicator in navigation */
|
||||
.agent-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-indicator:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.remote {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 6px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.multiple {
|
||||
background: var(--accent-orange);
|
||||
box-shadow: 0 0 6px var(--accent-orange);
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-indicator-count {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Agent selector dropdown */
|
||||
.agent-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-header h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.agent-selector-manage {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agent-selector-manage:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.agent-selector-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-selector-item:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.agent-selector-item.selected {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-selector-item.local {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-status.online {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status.offline {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-url {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-check {
|
||||
color: var(--accent-green);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item.selected .agent-selector-item-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Agent badge in data displays */
|
||||
.agent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-badge.local,
|
||||
.agent-badge.agent-local {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-badge.agent-remote {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* WiFi table agent column */
|
||||
.wifi-networks-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wifi-networks-table th.col-agent {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Bluetooth table agent column */
|
||||
.bt-devices-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Agent column in data tables */
|
||||
.data-table .agent-col {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Multi-agent stream indicator */
|
||||
.multi-agent-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.multi-agent-indicator.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.multi-agent-indicator-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* Agent connection status toast */
|
||||
.agent-toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
padding: 10px 15px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-toast.connected {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-toast.disconnected {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.agent-indicator {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.agents-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+126
-51
@@ -8,27 +8,30 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-green: #22c55e;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--radar-cyan: #4a9eff;
|
||||
--radar-bg: #0f1218;
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: #4aa3ff;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-green: #38c180;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-red: #e25d5d;
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #4aa3ff;
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
@@ -43,9 +46,10 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -58,10 +62,12 @@ body {
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.3;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -89,6 +95,18 @@ body {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: 12px 20px;
|
||||
@@ -97,7 +115,7 @@ body {
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
@@ -132,10 +150,49 @@ body {
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.agent-selector-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-select-sm {
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-compact .agent-status-dot.offline {
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 6px var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-compact .show-all-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
@@ -183,7 +240,7 @@ body {
|
||||
}
|
||||
|
||||
.strip-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
@@ -287,7 +344,7 @@ body {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||
white-space: nowrap;
|
||||
@@ -314,20 +371,21 @@ body {
|
||||
}
|
||||
|
||||
.strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Main dashboard grid - Mobile first */
|
||||
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
|
||||
.dashboard {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: calc(100dvh - 95px);
|
||||
height: calc(100vh - 95px);
|
||||
height: calc(100dvh - 160px);
|
||||
height: calc(100vh - 160px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@@ -367,7 +425,7 @@ body {
|
||||
/* Leaflet overrides - Dark map styling */
|
||||
.leaflet-container {
|
||||
background: var(--bg-dark) !important;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Using actual dark tiles now - no filter needed */
|
||||
@@ -438,7 +496,7 @@ body {
|
||||
padding: 10px 15px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
@@ -510,7 +568,7 @@ body {
|
||||
}
|
||||
|
||||
.vessel-name {
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
@@ -518,7 +576,7 @@ body {
|
||||
}
|
||||
|
||||
.vessel-mmsi {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
@@ -548,7 +606,7 @@ body {
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -604,20 +662,20 @@ body {
|
||||
}
|
||||
|
||||
.vessel-item-name {
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.vessel-item-type {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vessel-item-speed {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-align: right;
|
||||
@@ -628,14 +686,31 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch !important;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.03);
|
||||
border: 1px solid rgba(74, 158, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group > .control-group-items {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
@@ -687,7 +762,7 @@ body {
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -698,7 +773,7 @@ body {
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -717,7 +792,7 @@ body {
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -834,7 +909,7 @@ body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
min-height: calc(100dvh - 95px);
|
||||
min-height: calc(100dvh - 160px);
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -1004,7 +1079,7 @@ body {
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -1079,7 +1154,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-message-category {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -1096,13 +1171,13 @@ body {
|
||||
}
|
||||
|
||||
.dsc-message-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.dsc-message-mmsi {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
@@ -1120,7 +1195,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-message-pos {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -1148,7 +1223,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-distress-alert .dsc-alert-header {
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-red);
|
||||
@@ -1157,7 +1232,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-distress-alert .dsc-alert-mmsi {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 8px;
|
||||
@@ -1177,7 +1252,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-distress-alert .dsc-alert-position {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 16px;
|
||||
@@ -1188,7 +1263,7 @@ body {
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 24px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
/* Function Strip (Action Bar) - Shared across modes
|
||||
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
|
||||
*/
|
||||
|
||||
.function-strip {
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.function-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Strip title badge */
|
||||
.function-strip .strip-title {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.function-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
.function-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.function-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.function-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Signal stat coloring */
|
||||
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.function-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.function-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.function-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.function-strip .strip-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.function-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.function-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.function-strip .strip-input:hover,
|
||||
.function-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.function-strip .strip-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Wider input for frequency values */
|
||||
.function-strip .strip-input.wide {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.function-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.function-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-tool.warn {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
color: var(--accent-yellow);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.function-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.function-strip .strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.function-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.function-strip .status-dot.inactive {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.function-strip .status-dot.active,
|
||||
.function-strip .status-dot.scanning,
|
||||
.function-strip .status-dot.decoding {
|
||||
background: var(--accent-cyan);
|
||||
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.listening,
|
||||
.function-strip .status-dot.tracking,
|
||||
.function-strip .status-dot.receiving {
|
||||
background: var(--accent-green);
|
||||
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.sweeping {
|
||||
background: var(--accent-orange);
|
||||
animation: strip-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.function-strip .strip-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mode-specific accent colors */
|
||||
.function-strip.pager-strip .strip-stat {
|
||||
background: rgba(255, 193, 7, 0.05);
|
||||
border-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
.function-strip.pager-strip .strip-stat:hover {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
.function-strip.pager-strip .strip-value {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.function-strip.sensor-strip .strip-stat {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-color: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
.function-strip.sensor-strip .strip-stat:hover {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
.function-strip.sensor-strip .strip-value {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.function-strip.bt-strip .strip-stat {
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
border-color: rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
.function-strip.bt-strip .strip-stat:hover {
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
border-color: rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
.function-strip.bt-strip .strip-value {
|
||||
color: #0a84ff;
|
||||
}
|
||||
|
||||
.function-strip.wifi-strip .strip-stat {
|
||||
background: rgba(255, 149, 0, 0.05);
|
||||
border-color: rgba(255, 149, 0, 0.15);
|
||||
}
|
||||
.function-strip.wifi-strip .strip-stat:hover {
|
||||
background: rgba(255, 149, 0, 0.1);
|
||||
border-color: rgba(255, 149, 0, 0.3);
|
||||
}
|
||||
.function-strip.wifi-strip .strip-value {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.function-strip.tscm-strip {
|
||||
margin-top: 4px; /* Extra clearance to prevent top clipping */
|
||||
}
|
||||
|
||||
.function-strip.tscm-strip .strip-stat {
|
||||
background: rgba(255, 59, 48, 0.15);
|
||||
border: 1px solid rgba(255, 59, 48, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-stat:hover {
|
||||
background: rgba(255, 59, 48, 0.25);
|
||||
border-color: rgba(255, 59, 48, 0.6);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-value {
|
||||
color: #ef4444; /* Explicit red color */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-label {
|
||||
color: #9ca3af; /* Explicit light gray */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-select {
|
||||
color: #e8eaed; /* Explicit white for selects */
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-btn {
|
||||
color: #e8eaed; /* Explicit white for buttons */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-tool {
|
||||
color: #e8eaed; /* Explicit white for tool indicators */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-time,
|
||||
.function-strip.tscm-strip .strip-status span {
|
||||
color: #9ca3af; /* Explicit gray for status/time */
|
||||
}
|
||||
|
||||
.function-strip.rtlamr-strip .strip-stat {
|
||||
background: rgba(175, 82, 222, 0.05);
|
||||
border-color: rgba(175, 82, 222, 0.15);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-stat:hover {
|
||||
background: rgba(175, 82, 222, 0.1);
|
||||
border-color: rgba(175, 82, 222, 0.3);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-value {
|
||||
color: #af52de;
|
||||
}
|
||||
|
||||
.function-strip.listening-strip .strip-stat {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border-color: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
.function-strip.listening-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.function-strip.listening-strip .strip-value {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Threat-colored stats for TSCM */
|
||||
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
|
||||
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
|
||||
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
|
||||
@@ -14,10 +14,18 @@
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
transform-origin: center center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
+1934
-1924
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+626
-626
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* INTERCEPT Base Styles
|
||||
* Reset, typography, and foundational element styles
|
||||
* Requires: variables.css to be imported first
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS RESET
|
||||
============================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
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;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
h4 { font-size: var(--text-xl); }
|
||||
h5 { font-size: var(--text-lg); }
|
||||
h6 { font-size: var(--text-base); }
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-cyan-hover);
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
strong, b {
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
code, kbd, pre, samp {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FORM ELEMENTS
|
||||
============================================ */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TABLES
|
||||
============================================ */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LISTS
|
||||
============================================ */
|
||||
ul, ol {
|
||||
padding-left: var(--space-6);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
/* Text colors */
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-cyan { color: var(--accent-cyan); }
|
||||
.text-green { color: var(--accent-green); }
|
||||
.text-red { color: var(--accent-red); }
|
||||
.text-orange { color: var(--accent-orange); }
|
||||
.text-amber { color: var(--accent-amber); }
|
||||
|
||||
/* Font utilities */
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
|
||||
/* Text sizes */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
|
||||
/* Display */
|
||||
.hidden { display: none !important; }
|
||||
.block { display: block; }
|
||||
.inline-block { display: inline-block; }
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.grid { display: grid; }
|
||||
|
||||
/* Flexbox */
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.flex-1 { flex: 1; }
|
||||
.gap-1 { gap: var(--space-1); }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
|
||||
/* Spacing */
|
||||
.m-0 { margin: 0; }
|
||||
.mt-2 { margin-top: var(--space-2); }
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.p-2 { padding: var(--space-2); }
|
||||
.p-3 { padding: var(--space-3); }
|
||||
.p-4 { padding: var(--space-4); }
|
||||
|
||||
/* Borders */
|
||||
.rounded { border-radius: var(--radius-md); }
|
||||
.rounded-lg { border-radius: var(--radius-lg); }
|
||||
.border { border: 1px solid var(--border-color); }
|
||||
|
||||
/* Truncate text */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Screen reader only */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SCROLLBAR STYLING
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-light);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-light) var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SELECTION
|
||||
============================================ */
|
||||
::selection {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UX POLISH - TRANSITIONS & INTERACTIONS
|
||||
============================================ */
|
||||
|
||||
/* Smooth page transitions */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Better focus ring for all interactive elements */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Remove focus ring for mouse users */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Active state feedback */
|
||||
button:active:not(:disabled),
|
||||
a:active,
|
||||
[role="button"]:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[role="button"] {
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Subtle hover lift effect for cards and panels */
|
||||
.card:hover,
|
||||
.panel:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Link underline on hover */
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip link for accessibility */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
z-index: 9999;
|
||||
transition: top var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-color: #4b5563;
|
||||
--text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
/**
|
||||
* INTERCEPT UI Components
|
||||
* Reusable component styles for buttons, cards, badges, etc.
|
||||
* Requires: variables.css and base.css
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
BUTTONS
|
||||
============================================ */
|
||||
|
||||
/* Base button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn-primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-cyan-hover);
|
||||
border-color: var(--accent-cyan-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
/* Button sizes */
|
||||
.btn-sm {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.btn-icon {
|
||||
padding: var(--space-2);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.btn-icon.btn-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CARDS / PANELS
|
||||
============================================ */
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Panel variant (used in dashboards) */
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header::before,
|
||||
.panel-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--space-3);
|
||||
width: 36px;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.panel-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--status-offline);
|
||||
}
|
||||
|
||||
.panel-indicator.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 8px var(--status-online);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BADGES
|
||||
============================================ */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--accent-green-dim);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--accent-orange-dim);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--accent-red-dim);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DATA TAGS
|
||||
============================================ */
|
||||
.data-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-glow);
|
||||
}
|
||||
|
||||
.data-tag--accent {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.data-tag--warning {
|
||||
border-color: var(--accent-amber);
|
||||
color: var(--accent-amber);
|
||||
background: var(--accent-amber-dim);
|
||||
}
|
||||
|
||||
.data-tag--success {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
background: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.data-tag--danger {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
background: var(--accent-red-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATUS INDICATORS
|
||||
============================================ */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--status-offline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.online,
|
||||
.status-dot.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 4px var(--status-online);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: var(--status-warning);
|
||||
box-shadow: 0 0 4px var(--status-warning);
|
||||
}
|
||||
|
||||
.status-dot.error,
|
||||
.status-dot.offline {
|
||||
background: var(--status-error);
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Pulse animation for active status */
|
||||
.status-dot.pulse {
|
||||
animation: statusPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--space-4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-dim);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.empty-state-action {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOADING STATES
|
||||
============================================ */
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Loading overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-overlay);
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 25%,
|
||||
var(--bg-elevated) 50%,
|
||||
var(--bg-tertiary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS STRIP
|
||||
============================================ */
|
||||
.stats-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 var(--space-4);
|
||||
height: var(--stats-strip-height);
|
||||
overflow-x: auto;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.strip-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.strip-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color);
|
||||
margin: 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FORM GROUPS
|
||||
============================================ */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Inline checkbox/radio */
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-check input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.form-check label {
|
||||
margin-bottom: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERTS / TOASTS
|
||||
============================================ */
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--accent-green-dim);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: var(--accent-orange-dim);
|
||||
border-color: var(--accent-orange);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: var(--accent-red-dim);
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOOLTIPS
|
||||
============================================ */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||
z-index: var(--z-tooltip);
|
||||
pointer-events: none;
|
||||
margin-bottom: var(--space-1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ICONS
|
||||
============================================ */
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon--sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.icon--lg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SECTION HEADERS
|
||||
============================================ */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--accent-cyan);
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 6px 0 var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DIVIDERS
|
||||
============================================ */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--border-light),
|
||||
var(--border-light) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.divider-vertical {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
var(--border-light),
|
||||
var(--border-light) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
margin: 0 var(--space-3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UX POLISH - ENHANCED INTERACTIONS
|
||||
============================================ */
|
||||
|
||||
/* Button hover lift */
|
||||
.btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 0 1px var(--border-light);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
box-shadow: inset 0 0 0 1px var(--border-light);
|
||||
}
|
||||
|
||||
/* Card/Panel hover effects */
|
||||
.card,
|
||||
.panel {
|
||||
transition:
|
||||
box-shadow var(--transition-base),
|
||||
border-color var(--transition-base),
|
||||
transform var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.panel:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
/* Stats strip value highlight on hover */
|
||||
.strip-stat {
|
||||
transition: background-color var(--transition-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.strip-stat:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Status dot pulse animation */
|
||||
.status-dot.online,
|
||||
.status-dot.active {
|
||||
animation: statusGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 4px var(--status-online);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 8px var(--status-online), 0 0 12px var(--status-online);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge hover effect */
|
||||
.badge {
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Alert entrance animation */
|
||||
.alert {
|
||||
animation: alertSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes alertSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinner smooth appearance */
|
||||
.spinner {
|
||||
animation: spin 0.8s linear infinite, fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Input focus glow */
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
/* Nav item active indicator */
|
||||
.nav-item,
|
||||
.mode-nav-btn,
|
||||
.mobile-nav-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item.active::after,
|
||||
.mode-nav-btn.active::after,
|
||||
.mobile-nav-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12%;
|
||||
right: 12%;
|
||||
bottom: 2px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.75;
|
||||
animation: railPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes railPulse {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Smooth tooltip appearance */
|
||||
[data-tooltip]::after {
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
transform: translateX(-50%) translateY(-4px);
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* Disabled state with better visual feedback */
|
||||
:disabled,
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(30%);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* INTERCEPT Design Tokens
|
||||
* Single source of truth for colors, spacing, typography, and effects
|
||||
* Import this file FIRST in any stylesheet that needs design tokens
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ============================================
|
||||
COLOR PALETTE - Dark Theme (Default)
|
||||
============================================ */
|
||||
|
||||
/* Backgrounds - layered depth system */
|
||||
--bg-primary: #0b1118;
|
||||
--bg-secondary: #101823;
|
||||
--bg-tertiary: #151f2b;
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
|
||||
/* Accent colors */
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||
--accent-cyan-hover: #6bb3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||
--accent-red: #e25d5d;
|
||||
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-orange-dim: rgba(214, 168, 94, 0.16);
|
||||
--accent-amber: #d6a85e;
|
||||
--accent-amber-dim: rgba(214, 168, 94, 0.18);
|
||||
--accent-yellow: #e1c26b;
|
||||
--accent-purple: #8f7bd6;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--text-muted: #445266;
|
||||
--text-inverse: #0b1118;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #263246;
|
||||
--border-light: #354458;
|
||||
--border-glow: rgba(74, 163, 255, 0.25);
|
||||
--border-focus: var(--accent-cyan);
|
||||
|
||||
/* Status colors */
|
||||
--status-online: #38c180;
|
||||
--status-warning: #d6a85e;
|
||||
--status-error: #e25d5d;
|
||||
--status-offline: #6f7f94;
|
||||
--status-info: #4aa3ff;
|
||||
|
||||
/* Subtle grid/pattern */
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--grid-dot: rgba(255, 255, 255, 0.03);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
|
||||
/* ============================================
|
||||
SPACING SCALE
|
||||
============================================ */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 10px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 20px;
|
||||
--text-3xl: 24px;
|
||||
--text-4xl: 30px;
|
||||
|
||||
/* Font weights */
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* Line heights */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* ============================================
|
||||
BORDERS & RADIUS
|
||||
============================================ */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 8px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ============================================
|
||||
SHADOWS
|
||||
============================================ */
|
||||
--shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 12px 18px rgba(0, 0, 0, 0.45);
|
||||
--shadow-glow: 0 0 18px rgba(74, 163, 255, 0.16);
|
||||
|
||||
/* ============================================
|
||||
TRANSITIONS
|
||||
============================================ */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* ============================================
|
||||
Z-INDEX SCALE
|
||||
============================================ */
|
||||
--z-base: 0;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-fixed: 300;
|
||||
--z-modal-backdrop: 400;
|
||||
--z-modal: 500;
|
||||
--z-toast: 600;
|
||||
--z-tooltip: 700;
|
||||
|
||||
/* ============================================
|
||||
LAYOUT
|
||||
============================================ */
|
||||
--header-height: 60px;
|
||||
--nav-height: 44px;
|
||||
--sidebar-width: 280px;
|
||||
--stats-strip-height: 36px;
|
||||
--content-max-width: 1400px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIGHT THEME OVERRIDES
|
||||
============================================ */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f4f7fb;
|
||||
--bg-secondary: #e9eef5;
|
||||
--bg-tertiary: #dde5f0;
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #f1f4f9;
|
||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
--bg-panel: var(--bg-secondary);
|
||||
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||
--accent-cyan-hover: #2c73bf;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-green-dim: rgba(31, 138, 87, 0.12);
|
||||
--accent-red: #c74444;
|
||||
--accent-red-dim: rgba(199, 68, 68, 0.12);
|
||||
--accent-orange: #b5863a;
|
||||
--accent-orange-dim: rgba(181, 134, 58, 0.12);
|
||||
--accent-amber: #b5863a;
|
||||
--accent-amber-dim: rgba(181, 134, 58, 0.12);
|
||||
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--text-muted: #aab6c8;
|
||||
--text-inverse: #f4f7fb;
|
||||
|
||||
--border-color: #d1d9e6;
|
||||
--border-light: #c1ccdb;
|
||||
--border-glow: rgba(31, 95, 168, 0.12);
|
||||
|
||||
--grid-line: rgba(31, 95, 168, 0.14);
|
||||
--grid-dot: rgba(12, 18, 24, 0.06);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||
--shadow-glow: 0 0 18px rgba(31, 95, 168, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REDUCED MOTION
|
||||
============================================ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-fast: 0ms;
|
||||
--transition-base: 0ms;
|
||||
--transition-slow: 0ms;
|
||||
}
|
||||
}
|
||||
+18
-67
@@ -1,67 +1,18 @@
|
||||
/* Local font declarations for offline mode */
|
||||
|
||||
/* Inter - Primary UI font */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* JetBrains Mono - Monospace/code font */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||
}
|
||||
/* Local font declarations for offline mode */
|
||||
|
||||
/* Space Mono - Console font */
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/SpaceMono-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);
|
||||
}
|
||||
+289
-116
@@ -1,5 +1,3 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -7,61 +5,72 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
/* Tactical dark palette */
|
||||
--bg-primary: #0a0c10;
|
||||
--bg-secondary: #0f1218;
|
||||
--bg-tertiary: #151a23;
|
||||
--bg-card: #121620;
|
||||
--bg-elevated: #1a202c;
|
||||
--bg-primary: #0b1118;
|
||||
--bg-secondary: #101823;
|
||||
--bg-tertiary: #151f2b;
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
|
||||
/* Accent colors - sophisticated blue/amber */
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-cyan-dim: rgba(74, 158, 255, 0.15);
|
||||
--accent-green: #22c55e;
|
||||
--accent-green-dim: rgba(34, 197, 94, 0.15);
|
||||
--accent-red: #ef4444;
|
||||
--accent-red-dim: rgba(239, 68, 68, 0.15);
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-amber: #d4a853;
|
||||
--accent-amber-dim: rgba(212, 168, 83, 0.15);
|
||||
/* Accent colors - slate/cyan */
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||
--accent-green: #38c180;
|
||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||
--accent-red: #e25d5d;
|
||||
--accent-red-dim: rgba(226, 93, 93, 0.16);
|
||||
--accent-orange: #d6a85e;
|
||||
--accent-amber: #d6a85e;
|
||||
--accent-amber-dim: rgba(214, 168, 94, 0.18);
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--text-muted: #374151;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--text-muted: #445266;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #1f2937;
|
||||
--border-light: #374151;
|
||||
--border-glow: rgba(74, 158, 255, 0.2);
|
||||
--border-color: #263246;
|
||||
--border-light: #354458;
|
||||
--border-glow: rgba(74, 163, 255, 0.25);
|
||||
|
||||
/* Status colors */
|
||||
--status-online: #22c55e;
|
||||
--status-warning: #f59e0b;
|
||||
--status-error: #ef4444;
|
||||
--status-offline: #6b7280;
|
||||
--status-online: #38c180;
|
||||
--status-warning: #d6a85e;
|
||||
--status-error: #e25d5d;
|
||||
--status-offline: #6f7f94;
|
||||
|
||||
/* 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");
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #f1f5f9;
|
||||
--bg-tertiary: #e2e8f0;
|
||||
--bg-primary: #f4f7fb;
|
||||
--bg-secondary: #e9eef5;
|
||||
--bg-tertiary: #dde5f0;
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #f8fafc;
|
||||
--accent-cyan: #2563eb;
|
||||
--accent-cyan-dim: rgba(37, 99, 235, 0.1);
|
||||
--accent-green: #16a34a;
|
||||
--accent-red: #dc2626;
|
||||
--accent-orange: #d97706;
|
||||
--accent-amber: #b45309;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-dim: #94a3b8;
|
||||
--text-muted: #cbd5e1;
|
||||
--border-color: #e2e8f0;
|
||||
--border-light: #cbd5e1;
|
||||
--border-glow: rgba(37, 99, 235, 0.15);
|
||||
--bg-elevated: #f1f4f9;
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-cyan-dim: rgba(31, 95, 168, 0.12);
|
||||
--accent-green: #1f8a57;
|
||||
--accent-red: #c74444;
|
||||
--accent-orange: #b5863a;
|
||||
--accent-amber: #b5863a;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--text-muted: #aab6c8;
|
||||
--border-color: #d1d9e6;
|
||||
--border-light: #c1ccdb;
|
||||
--border-glow: rgba(31, 95, 168, 0.12);
|
||||
|
||||
--grid-line: rgba(31, 95, 168, 0.14);
|
||||
--grid-dot: rgba(12, 18, 24, 0.06);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
@@ -73,8 +82,16 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
font-family: var(--font-sans);
|
||||
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;
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
@@ -108,8 +125,8 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(74, 158, 255, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 100%);
|
||||
radial-gradient(circle at 50% 50%, rgba(74, 163, 255, 0.04) 0%, transparent 50%),
|
||||
linear-gradient(180deg, transparent 0%, rgba(74, 163, 255, 0.03) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -259,7 +276,7 @@ body {
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
@@ -269,7 +286,7 @@ body {
|
||||
}
|
||||
|
||||
.welcome-tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.15em;
|
||||
@@ -278,7 +295,7 @@ body {
|
||||
|
||||
.welcome-version {
|
||||
display: inline-block;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--bg-primary);
|
||||
background: var(--accent-cyan);
|
||||
@@ -297,7 +314,7 @@ body {
|
||||
}
|
||||
|
||||
.welcome-content h2 {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
@@ -333,14 +350,14 @@ body {
|
||||
}
|
||||
|
||||
.changelog-version {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.changelog-date {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@@ -352,7 +369,7 @@ body {
|
||||
}
|
||||
|
||||
.changelog-list li {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
@@ -364,7 +381,7 @@ body {
|
||||
position: absolute;
|
||||
left: -15px;
|
||||
color: var(--accent-green);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Mode Selection Grid */
|
||||
@@ -435,7 +452,7 @@ body {
|
||||
}
|
||||
|
||||
.mode-card .mode-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -444,7 +461,7 @@ body {
|
||||
}
|
||||
|
||||
.mode-card .mode-desc {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
@@ -463,7 +480,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@@ -517,7 +534,7 @@ body {
|
||||
}
|
||||
|
||||
.welcome-footer p {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
@@ -731,7 +748,7 @@ header h1 {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -778,7 +795,7 @@ header h1 {
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
@@ -814,7 +831,7 @@ header h1 {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -922,7 +939,7 @@ header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
@@ -978,6 +995,18 @@ header h1 {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Donate button - warm amber accent */
|
||||
.nav-tool-btn--donate {
|
||||
text-decoration: none;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.nav-tool-btn--donate:hover {
|
||||
color: var(--accent-orange);
|
||||
border-color: var(--accent-amber);
|
||||
background: rgba(212, 168, 83, 0.1);
|
||||
}
|
||||
|
||||
/* Theme toggle icon states in nav bar */
|
||||
.nav-tool-btn .icon-sun,
|
||||
.nav-tool-btn .icon-moon {
|
||||
@@ -1018,7 +1047,7 @@ header h1 {
|
||||
.version-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 500;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
@@ -1077,7 +1106,7 @@ header h1 .tagline {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -1566,7 +1595,7 @@ header h1 .tagline {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
@@ -1578,6 +1607,11 @@ header h1 .tagline {
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
/* Ensure device select is wide enough for device name + serial */
|
||||
#deviceSelect {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1620,7 +1654,7 @@ header h1 .tagline {
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -1640,7 +1674,7 @@ header h1 .tagline {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -1677,7 +1711,7 @@ header h1 .tagline {
|
||||
background: var(--accent-red);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -1740,7 +1774,7 @@ header h1 .tagline {
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.stats>div {
|
||||
@@ -1766,7 +1800,7 @@ header h1 .tagline {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 0; /* Allow shrinking in flex context */
|
||||
@@ -1838,7 +1872,7 @@ header h1 .tagline {
|
||||
|
||||
.message .address {
|
||||
color: var(--accent-green);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -1851,7 +1885,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.message .content.numeric {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--accent-cyan);
|
||||
@@ -2072,7 +2106,7 @@ header h1 .tagline {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
@@ -2340,7 +2374,7 @@ header h1 .tagline {
|
||||
/* Dark theme for Leaflet */
|
||||
.leaflet-container {
|
||||
background: #0a0a0a;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Using actual dark tiles now - no filter needed */
|
||||
@@ -2377,7 +2411,7 @@ header h1 .tagline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
z-index: 1000;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 5px var(--accent-cyan);
|
||||
@@ -2394,7 +2428,7 @@ header h1 .tagline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
z-index: 1000;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 5px var(--accent-cyan);
|
||||
@@ -2415,7 +2449,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.aircraft-popup {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -2459,7 +2493,7 @@ header h1 .tagline {
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
border: 1px solid var(--accent-cyan) !important;
|
||||
color: var(--accent-cyan) !important;
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 10px !important;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 2px !important;
|
||||
@@ -2487,7 +2521,7 @@ header h1 .tagline {
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s ease;
|
||||
@@ -2703,7 +2737,7 @@ header h1 .tagline {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
text-shadow: 0 0 15px var(--accent-cyan-dim);
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -3097,7 +3131,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.sensor-card .data-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -3147,7 +3181,7 @@ header h1 .tagline {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.recon-stats span {
|
||||
@@ -3197,14 +3231,14 @@ header h1 .tagline {
|
||||
|
||||
.device-id {
|
||||
color: var(--text-dim);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.device-meta.encrypted {
|
||||
@@ -3280,7 +3314,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.hex-dump {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-primary);
|
||||
@@ -3371,7 +3405,7 @@ header h1 .tagline {
|
||||
/* WiFi Main Content - 3 columns */
|
||||
.wifi-main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(240px, 280px) minmax(240px, 280px);
|
||||
grid-template-columns: minmax(300px, 1fr) minmax(240px, 280px) minmax(240px, 280px);
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -3386,6 +3420,7 @@ header h1 .tagline {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* Prevent content from forcing panel wider */
|
||||
}
|
||||
|
||||
.wifi-networks-header {
|
||||
@@ -3553,6 +3588,8 @@ header h1 .tagline {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
min-width: 0; /* Prevent content from forcing panel wider */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wifi-radar-panel h5 {
|
||||
@@ -3788,10 +3825,90 @@ header h1 .tagline {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wifi-client-count-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wifi-client-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wifi-client-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wifi-client-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wifi-client-mac {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wifi-client-vendor {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.wifi-client-probes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.wifi-client-probe-badge {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wifi-client-signal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wifi-client-rssi {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wifi-client-lastseen {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* WiFi Responsive */
|
||||
@media (max-width: 1400px) {
|
||||
.wifi-main-content {
|
||||
grid-template-columns: 1fr 240px 240px;
|
||||
grid-template-columns: minmax(280px, 1fr) 240px 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3949,7 +4066,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.bt-detail-address {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: #00d4ff;
|
||||
}
|
||||
@@ -3963,7 +4080,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.bt-detail-rssi-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -4058,7 +4175,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.bt-detail-services-list {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
@@ -4084,6 +4201,12 @@ header h1 .tagline {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.bt-detail-btn.active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.6);
|
||||
color: #9fffd1;
|
||||
}
|
||||
|
||||
/* Selected device highlight */
|
||||
.bt-device-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
@@ -4092,10 +4215,37 @@ header h1 .tagline {
|
||||
|
||||
.bt-device-list {
|
||||
border-left-color: var(--accent-purple) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
max-height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bt-device-list .wifi-device-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bt-device-list .wifi-device-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-device-list .wifi-device-list-header h5 {
|
||||
color: var(--accent-purple);
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Bluetooth Device Filters */
|
||||
@@ -4105,6 +4255,7 @@ header h1 .tagline {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-filter-btn {
|
||||
@@ -4247,6 +4398,17 @@ header h1 .tagline {
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.bt-history-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.bt-device-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -4277,7 +4439,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.bt-rssi-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 28px;
|
||||
@@ -4636,7 +4798,7 @@ header h1 .tagline {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.security-legend-item {
|
||||
@@ -4683,7 +4845,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.signal-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 10px var(--accent-cyan-dim);
|
||||
@@ -4836,7 +4998,7 @@ body::before {
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 12px 40px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
@@ -5199,7 +5361,7 @@ body::before {
|
||||
|
||||
.meter-value {
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
@@ -5356,7 +5518,7 @@ body::before {
|
||||
}
|
||||
|
||||
.freq-digits {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
@@ -5377,7 +5539,7 @@ body::before {
|
||||
}
|
||||
|
||||
.freq-unit {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 8px;
|
||||
@@ -5521,7 +5683,7 @@ body::before {
|
||||
}
|
||||
|
||||
.knob-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
@@ -5646,7 +5808,7 @@ body::before {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
@@ -5708,13 +5870,13 @@ body::before {
|
||||
}
|
||||
|
||||
.signal-arc-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
|
||||
.signal-arc-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
fill: var(--accent-cyan);
|
||||
@@ -5746,7 +5908,7 @@ body::before {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
@@ -5882,7 +6044,7 @@ body::before {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 15px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -5971,7 +6133,7 @@ body::before {
|
||||
}
|
||||
|
||||
.module-header {
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
@@ -5996,7 +6158,7 @@ body::before {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
@@ -6035,16 +6197,27 @@ body::before {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.radio-action-btn.scan {
|
||||
.radio-action-btn.scan,
|
||||
.radio-action-btn.listen {
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.radio-action-btn.scan:hover:not(:disabled) {
|
||||
.radio-action-btn.scan:hover:not(:disabled),
|
||||
.radio-action-btn.listen:hover:not(:disabled) {
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
.radio-action-btn.listen.active {
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.radio-action-btn.listen.active:hover:not(:disabled) {
|
||||
box-shadow: 0 0 20px var(--accent-red-dim);
|
||||
}
|
||||
|
||||
/* Statistics Box */
|
||||
.stat-box {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
@@ -6054,7 +6227,7 @@ body::before {
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -6102,7 +6275,7 @@ body::before {
|
||||
.tune-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
@@ -6132,13 +6305,13 @@ body::before {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Listening Mode Selector Buttons */
|
||||
.radio-mode-btn {
|
||||
padding: 12px 24px;
|
||||
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -6179,7 +6352,7 @@ body::before {
|
||||
/* Frequency Preset Buttons */
|
||||
.preset-freq-btn {
|
||||
padding: 8px 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -6243,4 +6416,4 @@ body::before {
|
||||
[data-animations="off"] .logo-dot,
|
||||
[data-animations="off"] .welcome-logo {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
/* Typography */
|
||||
.landing-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4em;
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
|
||||
.landing-tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.15em;
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
/* Hacker Style Error */
|
||||
.flash-error {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent-red);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
@@ -94,7 +94,7 @@
|
||||
color: var(--accent-cyan);
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
box-sizing: border-box; /* Crucial for visibility */
|
||||
@@ -106,7 +106,7 @@
|
||||
border: 2px solid var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
padding: 15px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
letter-spacing: 3px;
|
||||
cursor: pointer;
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
.landing-version {
|
||||
margin-top: 25px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
letter-spacing: 2px;
|
||||
|
||||
+328
-328
@@ -1,328 +1,328 @@
|
||||
/* APRS Function Bar (Stats Strip) Styles */
|
||||
.aprs-strip {
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.aprs-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
.aprs-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
.aprs-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.aprs-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.aprs-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
/* Signal stat coloring */
|
||||
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.aprs-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.aprs-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.aprs-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.aprs-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.aprs-strip .strip-input:hover,
|
||||
.aprs-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.aprs-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.aprs-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aprs-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
.aprs-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.aprs-strip .strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-strip .status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.tracking {
|
||||
background: var(--accent-green);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
@keyframes aprs-strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.aprs-strip .strip-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* APRS Status Bar Styles (Sidebar - legacy) */
|
||||
.aprs-status-bar {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-status-dot.standby { background: var(--text-muted); }
|
||||
.aprs-status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-status-dot.tracking { background: var(--accent-green); }
|
||||
.aprs-status-dot.error { background: var(--accent-red); }
|
||||
@keyframes aprs-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
.aprs-status-text {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.aprs-status-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.aprs-stat {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-stat-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Signal Meter Styles */
|
||||
.aprs-signal-meter {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-meter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-meter-label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-meter-value {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--accent-cyan);
|
||||
min-width: 24px;
|
||||
}
|
||||
.aprs-meter-burst {
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-yellow);
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
animation: burst-flash 0.3s ease-out;
|
||||
}
|
||||
@keyframes burst-flash {
|
||||
0% { opacity: 1; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.aprs-meter-bar-container {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.aprs-meter-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg,
|
||||
var(--accent-green) 0%,
|
||||
var(--accent-cyan) 50%,
|
||||
var(--accent-yellow) 75%,
|
||||
var(--accent-red) 100%
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease-out;
|
||||
}
|
||||
.aprs-meter-bar.no-signal {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.aprs-meter-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.aprs-meter-status {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.aprs-meter-status.active {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.aprs-meter-status.no-signal {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
/* APRS Function Bar (Stats Strip) Styles */
|
||||
.aprs-strip {
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.aprs-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
.aprs-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
.aprs-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.aprs-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.aprs-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
/* Signal stat coloring */
|
||||
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.aprs-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.aprs-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.aprs-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.aprs-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.aprs-strip .strip-input:hover,
|
||||
.aprs-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.aprs-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.aprs-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aprs-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
.aprs-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.aprs-strip .strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-strip .status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.tracking {
|
||||
background: var(--accent-green);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
@keyframes aprs-strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.aprs-strip .strip-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* APRS Status Bar Styles (Sidebar - legacy) */
|
||||
.aprs-status-bar {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-status-dot.standby { background: var(--text-muted); }
|
||||
.aprs-status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-status-dot.tracking { background: var(--accent-green); }
|
||||
.aprs-status-dot.error { background: var(--accent-red); }
|
||||
@keyframes aprs-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
.aprs-status-text {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.aprs-status-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.aprs-stat {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-stat-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Signal Meter Styles */
|
||||
.aprs-signal-meter {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-meter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-meter-label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-meter-value {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--accent-cyan);
|
||||
min-width: 24px;
|
||||
}
|
||||
.aprs-meter-burst {
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-yellow);
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
animation: burst-flash 0.3s ease-out;
|
||||
}
|
||||
@keyframes burst-flash {
|
||||
0% { opacity: 1; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.aprs-meter-bar-container {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.aprs-meter-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg,
|
||||
var(--accent-green) 0%,
|
||||
var(--accent-cyan) 50%,
|
||||
var(--accent-yellow) 75%,
|
||||
var(--accent-red) 100%
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease-out;
|
||||
}
|
||||
.aprs-meter-bar.no-signal {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.aprs-meter-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.aprs-meter-status {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.aprs-meter-status.active {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.aprs-meter-status.no-signal {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
+1610
-1589
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
.spy-stations-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
|
||||
.spy-station-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
/* Type Badge */
|
||||
.spy-station-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -173,7 +173,7 @@
|
||||
}
|
||||
|
||||
.spy-meta-mode {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
@@ -186,7 +186,7 @@
|
||||
}
|
||||
|
||||
.spy-freq-list {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.6;
|
||||
@@ -199,7 +199,7 @@
|
||||
}
|
||||
|
||||
.spy-freq-item {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
background: var(--bg-secondary);
|
||||
@@ -236,7 +236,7 @@
|
||||
}
|
||||
|
||||
.spy-freq-select {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-secondary);
|
||||
@@ -273,7 +273,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* SSTV General Mode Styles
|
||||
* Terrestrial Slow-Scan Television decoder interface
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
MODE VISIBILITY
|
||||
============================================ */
|
||||
#sstvGeneralMode.active {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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; }
|
||||
}
|
||||
+1067
-876
File diff suppressed because it is too large
Load Diff
+1693
-1463
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+660
-660
File diff suppressed because it is too large
Load Diff
@@ -5,25 +5,28 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: #4aa3ff;
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-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 {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
@@ -38,9 +41,10 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
@@ -62,12 +66,14 @@ body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
height: 2px;
|
||||
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;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -92,8 +98,20 @@ body {
|
||||
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 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
@@ -142,7 +160,7 @@ body {
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -162,10 +180,45 @@ body {
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -211,6 +264,7 @@ body {
|
||||
}
|
||||
|
||||
/* Main dashboard grid */
|
||||
/* Header ~52px + Nav 44px = ~96px, using 100px for safety */
|
||||
.dashboard {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
@@ -218,7 +272,7 @@ body {
|
||||
grid-template-columns: 1fr 1fr 340px;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
height: calc(100vh - 60px);
|
||||
height: calc(100vh - 100px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
@@ -457,7 +511,7 @@ body {
|
||||
}
|
||||
|
||||
.telemetry-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -543,7 +597,7 @@ body {
|
||||
}
|
||||
|
||||
.pass-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Bottom controls bar */
|
||||
@@ -579,7 +633,7 @@ body {
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -696,7 +750,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 60px);
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.polar-container,
|
||||
@@ -748,4 +802,4 @@ body.embedded .panel {
|
||||
|
||||
body.embedded .controls-bar {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
+496
-399
@@ -1,399 +1,496 @@
|
||||
/* Settings Modal Styles */
|
||||
|
||||
.settings-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 10000;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.settings-modal.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
background: var(--bg-dark, #0a0a0f);
|
||||
border: 1px solid var(--border-color, #1a1a2e);
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-header h2 .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.settings-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.settings-close:hover {
|
||||
color: var(--accent-red, #ff4444);
|
||||
}
|
||||
|
||||
/* Settings Tabs */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||
padding: 0 20px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.settings-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Settings Sections */
|
||||
.settings-section {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #666);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Settings Row */
|
||||
.settings-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settings-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-label-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.settings-label-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: var(--text-muted, #666);
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background-color: var(--accent-cyan, #00d4ff);
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.toggle-switch input:focus + .toggle-slider {
|
||||
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
.settings-select {
|
||||
background: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
min-width: 160px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.settings-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Text Input */
|
||||
.settings-input {
|
||||
background: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.settings-input::placeholder {
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
/* Asset Status */
|
||||
.asset-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary, #0f0f1a);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.asset-status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.asset-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-badge.available {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: var(--accent-green, #00ff88);
|
||||
}
|
||||
|
||||
.asset-badge.missing {
|
||||
background: rgba(255, 68, 68, 0.15);
|
||||
color: var(--accent-red, #ff4444);
|
||||
}
|
||||
|
||||
.asset-badge.checking {
|
||||
background: rgba(255, 170, 0, 0.15);
|
||||
color: var(--accent-orange, #ffaa00);
|
||||
}
|
||||
|
||||
/* Check Assets Button */
|
||||
.check-assets-btn {
|
||||
background: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-top: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.check-assets-btn:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.check-assets-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* About Section */
|
||||
.about-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #888);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.about-info p {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.about-info a {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.about-version {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Tile Provider Custom URL */
|
||||
.custom-url-row {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.custom-url-row .settings-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Info Callout */
|
||||
.settings-info {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.settings-info strong {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.settings-modal.active {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-select,
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
/* Settings Modal Styles */
|
||||
|
||||
.settings-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 10000;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.settings-modal.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
background: var(--bg-dark, #0a0a0f);
|
||||
border: 1px solid var(--border-color, #1a1a2e);
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-header h2 .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.settings-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.settings-close:hover {
|
||||
color: var(--accent-red, #ff4444);
|
||||
}
|
||||
|
||||
/* Settings Tabs */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||
padding: 0 20px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.settings-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Settings Sections */
|
||||
.settings-section {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-section.active {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #666);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Settings Row */
|
||||
.settings-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settings-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-label-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.settings-label-desc {
|
||||
font-size: 11px;
|
||||
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 {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: var(--text-muted, #666);
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background-color: var(--accent-cyan, #00d4ff);
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.toggle-switch input:focus + .toggle-slider {
|
||||
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
.settings-select {
|
||||
background: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
min-width: 160px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.settings-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Text Input */
|
||||
.settings-input {
|
||||
background: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.settings-input::placeholder {
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
/* Asset Status */
|
||||
.asset-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary, #0f0f1a);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.asset-status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.asset-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-badge.available {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: var(--accent-green, #00ff88);
|
||||
}
|
||||
|
||||
.asset-badge.missing {
|
||||
background: rgba(255, 68, 68, 0.15);
|
||||
color: var(--accent-red, #ff4444);
|
||||
}
|
||||
|
||||
.asset-badge.checking {
|
||||
background: rgba(255, 170, 0, 0.15);
|
||||
color: var(--accent-orange, #ffaa00);
|
||||
}
|
||||
|
||||
/* Check Assets Button */
|
||||
.check-assets-btn {
|
||||
background: var(--bg-tertiary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a3e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-top: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.check-assets-btn:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.check-assets-btn:disabled {
|
||||
opacity: 0.5;
|
||||
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-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #888);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.about-info p {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.about-info a {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.about-version {
|
||||
font-family: var(--font-mono);
|
||||
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 */
|
||||
.custom-url-row {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.custom-url-row .settings-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Info Callout */
|
||||
.settings-info {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.settings-info strong {
|
||||
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 */
|
||||
@media (max-width: 640px) {
|
||||
.settings-modal.active {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-select,
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ const ProximityRadar = (function() {
|
||||
let activeFilter = null;
|
||||
let onDeviceClick = null;
|
||||
let selectedDeviceKey = null;
|
||||
let isHovered = false;
|
||||
let renderPending = false;
|
||||
let renderTimer = null;
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
@@ -162,8 +165,18 @@ const ProximityRadar = (function() {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Apply filter and render
|
||||
renderDevices();
|
||||
// Defer render while user is hovering to prevent DOM rebuild flicker
|
||||
if (isHovered) {
|
||||
renderPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid updates (e.g. per-device SSE events)
|
||||
if (renderTimer) clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(() => {
|
||||
renderTimer = null;
|
||||
renderDevices();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,25 +220,32 @@ const ProximityRadar = (function() {
|
||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
||||
|
||||
// Hit area size (prevents hover flicker when scaling)
|
||||
const hitAreaSize = Math.max(dotSize * 2, 15);
|
||||
|
||||
return `
|
||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
transform="translate(${x}, ${y})" style="cursor: pointer;">
|
||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
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 transform="translate(${x}, ${y})">
|
||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
style="cursor: pointer;">
|
||||
<!-- Invisible hit area to prevent hover flicker -->
|
||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
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>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
|
||||
// Attach click handlers
|
||||
// Attach event handlers
|
||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const deviceKey = el.getAttribute('data-device-key');
|
||||
@@ -233,6 +253,14 @@ const ProximityRadar = (function() {
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseenter', () => { isHovered = true; });
|
||||
el.addEventListener('mouseleave', () => {
|
||||
isHovered = false;
|
||||
if (renderPending) {
|
||||
renderPending = false;
|
||||
renderDevices();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -865,15 +865,12 @@ function connectAgentStream(mode, onMessage) {
|
||||
}
|
||||
|
||||
let streamUrl;
|
||||
if (currentAgent === 'local') {
|
||||
streamUrl = `/${mode}/stream`;
|
||||
} else {
|
||||
// For remote agents, we could either:
|
||||
// 1. Use the multi-agent stream: /controller/stream/all
|
||||
// 2. Or proxy through controller (not implemented yet)
|
||||
// For now, use multi-agent stream which includes agent_name tagging
|
||||
streamUrl = '/controller/stream/all';
|
||||
}
|
||||
if (currentAgent === 'local') {
|
||||
streamUrl = `/${mode}/stream`;
|
||||
} else {
|
||||
// For remote agents, proxy SSE through controller
|
||||
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
|
||||
}
|
||||
|
||||
agentEventSource = new EventSource(streamUrl);
|
||||
|
||||
@@ -881,15 +878,7 @@ function connectAgentStream(mode, onMessage) {
|
||||
try {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
+507
-504
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
});
|
||||
+985
-841
File diff suppressed because it is too large
Load Diff
@@ -366,7 +366,10 @@ const BluetoothMode = (function() {
|
||||
// Badges
|
||||
const badgesEl = document.getElementById('btDetailBadges');
|
||||
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
|
||||
if (device.is_tracker) {
|
||||
@@ -448,12 +451,14 @@ const BluetoothMode = (function() {
|
||||
? minMax[0] + '/' + minMax[1]
|
||||
: '--';
|
||||
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
|
||||
? new Date(device.first_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
document.getElementById('btDetailLastSeen').textContent = device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
|
||||
updateWatchlistButton(device);
|
||||
|
||||
// Services
|
||||
const servicesContainer = document.getElementById('btDetailServices');
|
||||
@@ -465,13 +470,29 @@ const BluetoothMode = (function() {
|
||||
servicesContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
// Show content, hide placeholder
|
||||
placeholder.style.display = 'none';
|
||||
content.style.display = 'block';
|
||||
|
||||
// Highlight selected device in list
|
||||
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
|
||||
@@ -525,24 +546,43 @@ const BluetoothMode = (function() {
|
||||
/**
|
||||
* Copy selected device address to clipboard
|
||||
*/
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
function copyAddress() {
|
||||
if (!selectedDeviceId) return;
|
||||
const device = devices.get(selectedDeviceId);
|
||||
if (!device) return;
|
||||
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.querySelector('.bt-detail-btn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
const btn = document.getElementById('btDetailCopyBtn');
|
||||
if (btn) {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#22c55e';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.background = '';
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1090,10 +1130,11 @@ const BluetoothMode = (function() {
|
||||
const isNew = !inBaseline;
|
||||
const hasName = !!device.name;
|
||||
const isTracker = device.is_tracker === true;
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const agentName = device._agent || 'Local';
|
||||
const seenBefore = device.seen_before === true;
|
||||
|
||||
// Calculate RSSI bar width (0-100%)
|
||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||
@@ -1145,8 +1186,9 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
// Add agent name if not Local
|
||||
if (agentName !== 'Local') {
|
||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||
@@ -1358,9 +1400,10 @@ const BluetoothMode = (function() {
|
||||
setBaseline,
|
||||
clearBaseline,
|
||||
exportData,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
selectDevice,
|
||||
clearSelection,
|
||||
copyAddress,
|
||||
toggleWatchlist,
|
||||
|
||||
// Agent handling
|
||||
handleAgentChange,
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
/**
|
||||
* Intercept - DMR / Digital Voice Mode
|
||||
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let isDmrRunning = false;
|
||||
let dmrEventSource = null;
|
||||
let dmrCallCount = 0;
|
||||
let dmrSyncCount = 0;
|
||||
let dmrCallHistory = [];
|
||||
let dmrCurrentProtocol = '--';
|
||||
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
|
||||
let dmrHasAudio = false;
|
||||
let dmrQualitySamples = [];
|
||||
let dmrQualityScore = null;
|
||||
let dmrSweepInProgress = false;
|
||||
|
||||
// ============== BOOKMARKS ==============
|
||||
let dmrBookmarks = [];
|
||||
const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
|
||||
const DMR_SETTINGS_KEY = 'dmrSettings';
|
||||
|
||||
// ============== SYNTHESIZER STATE ==============
|
||||
let dmrSynthCanvas = null;
|
||||
let dmrSynthCtx = null;
|
||||
let dmrSynthBars = [];
|
||||
let dmrSynthAnimationId = null;
|
||||
let dmrSynthInitialized = false;
|
||||
let dmrActivityLevel = 0;
|
||||
let dmrActivityTarget = 0;
|
||||
let dmrEventType = 'idle';
|
||||
let dmrLastEventTime = 0;
|
||||
const DMR_BAR_COUNT = 48;
|
||||
const DMR_DECAY_RATE = 0.015;
|
||||
const DMR_BURST_SYNC = 0.6;
|
||||
const DMR_BURST_CALL = 0.85;
|
||||
const DMR_BURST_VOICE = 0.95;
|
||||
|
||||
// ============== TOOLS CHECK ==============
|
||||
|
||||
function checkDmrTools() {
|
||||
fetch('/dmr/tools')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const warning = document.getElementById('dmrToolsWarning');
|
||||
const warningText = document.getElementById('dmrToolsWarningText');
|
||||
if (!warning) return;
|
||||
|
||||
const missing = [];
|
||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
||||
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
||||
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
|
||||
|
||||
if (missing.length > 0) {
|
||||
warning.style.display = 'block';
|
||||
if (warningText) warningText.textContent = missing.join(', ');
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update audio panel availability
|
||||
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== START / STOP ==============
|
||||
|
||||
function startDmr() {
|
||||
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
||||
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
||||
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
||||
const fineTune = parseInt(document.getElementById('dmrFineTune')?.value || 0);
|
||||
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
|
||||
const demod = document.getElementById('dmrDemod')?.value || 'nfm';
|
||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
|
||||
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
|
||||
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
||||
|
||||
// Check device availability before starting
|
||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings to localStorage for persistence
|
||||
try {
|
||||
localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({
|
||||
frequency, protocol, gain, ppm, fineTune, relaxCrc, demod
|
||||
}));
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
|
||||
return fetch('/dmr/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frequency, protocol, gain, device, ppm, fineTune, relaxCrc, demod })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isDmrRunning = true;
|
||||
dmrQualitySamples = [];
|
||||
dmrQualityScore = null;
|
||||
updateDmrQualityUI();
|
||||
dmrCallCount = 0;
|
||||
dmrSyncCount = 0;
|
||||
dmrCallHistory = [];
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof reserveDevice === 'function') {
|
||||
reserveDevice(parseInt(device), dmrModeLabel);
|
||||
}
|
||||
// Start audio if available
|
||||
dmrHasAudio = !!data.has_audio;
|
||||
if (dmrHasAudio) startDmrAudio();
|
||||
updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE');
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||
}
|
||||
} else if (data.status === 'error' && data.message === 'Already running') {
|
||||
// Backend has an active session the frontend lost track of — resync
|
||||
isDmrRunning = true;
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR', 'Reconnected to active session');
|
||||
}
|
||||
} else {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error', data.message || 'Failed to start DMR');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Start error:', err));
|
||||
}
|
||||
|
||||
function stopDmr() {
|
||||
stopDmrAudio();
|
||||
return fetch('/dmr/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isDmrRunning = false;
|
||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||
dmrQualitySamples = [];
|
||||
dmrQualityScore = null;
|
||||
updateDmrQualityUI();
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
updateDmrAudioStatus('OFF');
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice(dmrModeLabel);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Stop error:', err));
|
||||
}
|
||||
|
||||
// ============== SSE STREAMING ==============
|
||||
|
||||
function connectDmrSSE() {
|
||||
if (dmrEventSource) dmrEventSource.close();
|
||||
dmrEventSource = new EventSource('/dmr/stream');
|
||||
|
||||
dmrEventSource.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleDmrMessage(msg);
|
||||
};
|
||||
|
||||
dmrEventSource.onerror = function() {
|
||||
if (isDmrRunning) {
|
||||
setTimeout(connectDmrSSE, 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleDmrMessage(msg) {
|
||||
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
|
||||
|
||||
if (msg.type === 'sync') {
|
||||
dmrCurrentProtocol = msg.protocol || '--';
|
||||
const protocolEl = document.getElementById('dmrActiveProtocol');
|
||||
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
|
||||
const mainProtocolEl = document.getElementById('dmrMainProtocol');
|
||||
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
|
||||
dmrSyncCount++;
|
||||
const syncCountEl = document.getElementById('dmrSyncCount');
|
||||
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
|
||||
} else if (msg.type === 'call') {
|
||||
recordDmrQuality(true);
|
||||
dmrCallCount++;
|
||||
const countEl = document.getElementById('dmrCallCount');
|
||||
if (countEl) countEl.textContent = dmrCallCount;
|
||||
const mainCountEl = document.getElementById('dmrMainCallCount');
|
||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
||||
|
||||
// Update current call display
|
||||
const slotInfo = msg.slot != null ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Slot</span>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
||||
</div>` : '';
|
||||
const callEl = document.getElementById('dmrCurrentCall');
|
||||
if (callEl) {
|
||||
callEl.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Talkgroup</span>
|
||||
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Source ID</span>
|
||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
||||
</div>${slotInfo}
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-muted);">Time</span>
|
||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add to history
|
||||
dmrCallHistory.unshift({
|
||||
talkgroup: msg.talkgroup,
|
||||
source_id: msg.source_id,
|
||||
protocol: dmrCurrentProtocol,
|
||||
time: msg.timestamp,
|
||||
});
|
||||
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
|
||||
renderDmrHistory();
|
||||
|
||||
} else if (msg.type === 'slot') {
|
||||
// Update slot info in current call
|
||||
} else if (msg.type === 'frame_ok') {
|
||||
recordDmrQuality(true);
|
||||
} else if (msg.type === 'frame_error') {
|
||||
recordDmrQuality(false);
|
||||
} else if (msg.type === 'raw') {
|
||||
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
|
||||
} else if (msg.type === 'voice') {
|
||||
recordDmrQuality(true);
|
||||
} else if (msg.type === 'heartbeat') {
|
||||
// Decoder is alive and listening — keep synthesizer in listening state
|
||||
if (isDmrRunning && dmrSynthInitialized) {
|
||||
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
|
||||
dmrEventType = 'raw';
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'status') {
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (msg.text === 'started') {
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
} else if (msg.text === 'crashed') {
|
||||
isDmrRunning = false;
|
||||
stopDmrAudio();
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
updateDmrAudioStatus('OFF');
|
||||
if (statusEl) statusEl.textContent = 'CRASHED';
|
||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR Error', detail);
|
||||
}
|
||||
} else if (msg.text === 'stopped') {
|
||||
isDmrRunning = false;
|
||||
stopDmrAudio();
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
updateDmrAudioStatus('OFF');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== QUALITY METER ==============
|
||||
|
||||
function recordDmrQuality(ok) {
|
||||
dmrQualitySamples.push(!!ok);
|
||||
if (dmrQualitySamples.length > 200) dmrQualitySamples.shift();
|
||||
const total = dmrQualitySamples.length;
|
||||
if (total < 5) {
|
||||
dmrQualityScore = null;
|
||||
updateDmrQualityUI();
|
||||
return;
|
||||
}
|
||||
const errors = dmrQualitySamples.reduce((sum, v) => sum + (v ? 0 : 1), 0);
|
||||
dmrQualityScore = Math.max(0, Math.min(100, Math.round(100 * (1 - (errors / total)))));
|
||||
updateDmrQualityUI();
|
||||
}
|
||||
|
||||
function updateDmrQualityUI() {
|
||||
const textEl = document.getElementById('dmrQualityText');
|
||||
const barEl = document.getElementById('dmrQualityBar');
|
||||
if (!textEl || !barEl) return;
|
||||
if (dmrQualityScore == null) {
|
||||
textEl.textContent = '--';
|
||||
barEl.style.width = '0%';
|
||||
barEl.style.background = 'var(--text-muted)';
|
||||
return;
|
||||
}
|
||||
textEl.textContent = `${dmrQualityScore}%`;
|
||||
barEl.style.width = `${dmrQualityScore}%`;
|
||||
if (dmrQualityScore >= 80) {
|
||||
barEl.style.background = 'var(--accent-green)';
|
||||
} else if (dmrQualityScore >= 50) {
|
||||
barEl.style.background = 'var(--accent-amber, #f59e0b)';
|
||||
} else {
|
||||
barEl.style.background = 'var(--accent-red)';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== FINE TUNE SWEEP ==============
|
||||
|
||||
async function sweepDmrFineTune() {
|
||||
if (!isDmrRunning) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Digital Voice', 'Start the decoder before sweeping fine tune.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dmrSweepInProgress) return;
|
||||
dmrSweepInProgress = true;
|
||||
|
||||
const freqEl = document.getElementById('dmrFrequency');
|
||||
const protoEl = document.getElementById('dmrProtocol');
|
||||
const gainEl = document.getElementById('dmrGain');
|
||||
const ppmEl = document.getElementById('dmrPPM');
|
||||
const fineEl = document.getElementById('dmrFineTune');
|
||||
const crcEl = document.getElementById('dmrRelaxCrc');
|
||||
const demodEl = document.getElementById('dmrDemod');
|
||||
const sweepBtn = document.getElementById('dmrFineTuneSweepBtn');
|
||||
|
||||
const original = {
|
||||
frequency: freqEl?.value,
|
||||
protocol: protoEl?.value,
|
||||
gain: gainEl?.value,
|
||||
ppm: ppmEl?.value,
|
||||
fineTune: fineEl?.value,
|
||||
relaxCrc: crcEl?.checked,
|
||||
demod: demodEl?.value,
|
||||
};
|
||||
|
||||
if (sweepBtn) {
|
||||
sweepBtn.disabled = true;
|
||||
sweepBtn.textContent = 'Sweeping...';
|
||||
}
|
||||
|
||||
const offsets = [-2000, -1500, -1000, -500, 0, 500, 1000, 1500, 2000];
|
||||
let best = { offset: parseInt(original.fineTune || 0, 10) || 0, score: -1 };
|
||||
|
||||
for (const offset of offsets) {
|
||||
if (fineEl) fineEl.value = offset;
|
||||
await stopDmr();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
await startDmr();
|
||||
dmrQualitySamples = [];
|
||||
dmrQualityScore = null;
|
||||
updateDmrQualityUI();
|
||||
await new Promise(r => setTimeout(r, 700));
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
const score = dmrQualityScore == null ? 0 : dmrQualityScore;
|
||||
if (score > best.score) best = { offset, score };
|
||||
}
|
||||
|
||||
if (fineEl) fineEl.value = best.offset;
|
||||
await stopDmr();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
await startDmr();
|
||||
|
||||
if (sweepBtn) {
|
||||
sweepBtn.disabled = false;
|
||||
sweepBtn.textContent = 'Sweep Fine Tune';
|
||||
}
|
||||
dmrSweepInProgress = false;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Digital Voice', `Sweep complete: best offset ${best.offset} Hz (${best.score}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== UI ==============
|
||||
|
||||
function updateDmrUI() {
|
||||
const startBtn = document.getElementById('startDmrBtn');
|
||||
const stopBtn = document.getElementById('stopDmrBtn');
|
||||
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
|
||||
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderDmrHistory() {
|
||||
const container = document.getElementById('dmrHistoryBody');
|
||||
if (!container) return;
|
||||
|
||||
const historyCountEl = document.getElementById('dmrHistoryCount');
|
||||
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
|
||||
|
||||
if (dmrCallHistory.length === 0) {
|
||||
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
|
||||
<tr>
|
||||
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
|
||||
<td style="padding: 3px 6px;">${call.protocol}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== SYNTHESIZER ==============
|
||||
|
||||
function initDmrSynthesizer() {
|
||||
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
|
||||
if (!dmrSynthCanvas) return;
|
||||
|
||||
// Use the canvas element's own rendered size for the backing buffer
|
||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
||||
const w = Math.round(rect.width) || 600;
|
||||
const h = Math.round(rect.height) || 70;
|
||||
dmrSynthCanvas.width = w;
|
||||
dmrSynthCanvas.height = h;
|
||||
|
||||
dmrSynthCtx = dmrSynthCanvas.getContext('2d');
|
||||
|
||||
dmrSynthBars = [];
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 };
|
||||
}
|
||||
|
||||
dmrActivityLevel = 0;
|
||||
dmrActivityTarget = 0;
|
||||
dmrEventType = isDmrRunning ? 'idle' : 'stopped';
|
||||
dmrSynthInitialized = true;
|
||||
|
||||
updateDmrSynthStatus();
|
||||
|
||||
if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId);
|
||||
drawDmrSynthesizer();
|
||||
}
|
||||
|
||||
function drawDmrSynthesizer() {
|
||||
if (!dmrSynthCtx || !dmrSynthCanvas) return;
|
||||
|
||||
const width = dmrSynthCanvas.width;
|
||||
const height = dmrSynthCanvas.height;
|
||||
const barWidth = (width / DMR_BAR_COUNT) - 2;
|
||||
const now = Date.now();
|
||||
|
||||
// Clear canvas
|
||||
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
dmrSynthCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// Decay activity toward target. Window must exceed the backend
|
||||
// heartbeat interval (3s) so the status doesn't flip-flop between
|
||||
// LISTENING and IDLE on every heartbeat cycle.
|
||||
const timeSinceEvent = now - dmrLastEventTime;
|
||||
if (timeSinceEvent > 5000) {
|
||||
// No events for 5s — decay target toward idle
|
||||
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
||||
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
||||
dmrEventType = 'idle';
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth approach to target
|
||||
dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08;
|
||||
|
||||
// Determine effective activity (idle breathing when stopped/idle)
|
||||
let effectiveActivity = dmrActivityLevel;
|
||||
if (dmrEventType === 'stopped') {
|
||||
effectiveActivity = 0;
|
||||
} else if (effectiveActivity < 0.1 && isDmrRunning) {
|
||||
// Visible idle breathing — shows decoder is alive and listening
|
||||
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
|
||||
}
|
||||
|
||||
// Ripple timing for sync events
|
||||
const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0;
|
||||
// Voice ripple overlay
|
||||
const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0;
|
||||
|
||||
// Update bar targets and physics
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
const time = now / 200;
|
||||
const wave1 = Math.sin(time + i * 0.3) * 0.2;
|
||||
const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15;
|
||||
const randomAmount = 0.05 + effectiveActivity * 0.25;
|
||||
const random = (Math.random() - 0.5) * randomAmount;
|
||||
|
||||
// Bell curve — center bars taller
|
||||
const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2);
|
||||
const centerBoost = 1 - centerDist * 0.5;
|
||||
|
||||
// Sync ripple: center-outward wave burst
|
||||
let rippleBoost = 0;
|
||||
if (syncRippleAge > 0) {
|
||||
const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2;
|
||||
const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos;
|
||||
rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4;
|
||||
}
|
||||
|
||||
const baseHeight = 0.1 + effectiveActivity * 0.55;
|
||||
dmrSynthBars[i].targetHeight = Math.max(2,
|
||||
(baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) *
|
||||
effectiveActivity * centerBoost * height
|
||||
);
|
||||
|
||||
// Spring physics
|
||||
const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1;
|
||||
const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height;
|
||||
dmrSynthBars[i].velocity += diff * springStrength;
|
||||
dmrSynthBars[i].velocity *= 0.78;
|
||||
dmrSynthBars[i].height += dmrSynthBars[i].velocity;
|
||||
dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height));
|
||||
}
|
||||
|
||||
// Draw bars
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
const x = i * (barWidth + 2) + 1;
|
||||
const barHeight = dmrSynthBars[i].height;
|
||||
const y = (height - barHeight) / 2;
|
||||
|
||||
// HSL color by event type
|
||||
let hue, saturation, lightness;
|
||||
if (dmrEventType === 'voice' && timeSinceEvent < 3000) {
|
||||
hue = 30; // Orange
|
||||
saturation = 85;
|
||||
lightness = 40 + (barHeight / height) * 25;
|
||||
} else if (dmrEventType === 'call' && timeSinceEvent < 3000) {
|
||||
hue = 120; // Green
|
||||
saturation = 80;
|
||||
lightness = 35 + (barHeight / height) * 30;
|
||||
} else if (dmrEventType === 'sync' && timeSinceEvent < 2000) {
|
||||
hue = 185; // Cyan
|
||||
saturation = 85;
|
||||
lightness = 38 + (barHeight / height) * 25;
|
||||
} else if (dmrEventType === 'stopped') {
|
||||
hue = 220;
|
||||
saturation = 20;
|
||||
lightness = 18 + (barHeight / height) * 8;
|
||||
} else {
|
||||
// Idle / decayed
|
||||
hue = 210;
|
||||
saturation = 40;
|
||||
lightness = 25 + (barHeight / height) * 15;
|
||||
}
|
||||
|
||||
// Vertical gradient per bar
|
||||
const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight);
|
||||
gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
||||
gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
|
||||
gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
||||
|
||||
dmrSynthCtx.fillStyle = gradient;
|
||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
||||
|
||||
// Glow on tall bars
|
||||
if (barHeight > height * 0.5 && effectiveActivity > 0.4) {
|
||||
dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
|
||||
dmrSynthCtx.shadowBlur = 8;
|
||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
||||
dmrSynthCtx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Center line
|
||||
dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
||||
dmrSynthCtx.lineWidth = 1;
|
||||
dmrSynthCtx.beginPath();
|
||||
dmrSynthCtx.moveTo(0, height / 2);
|
||||
dmrSynthCtx.lineTo(width, height / 2);
|
||||
dmrSynthCtx.stroke();
|
||||
|
||||
dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer);
|
||||
}
|
||||
|
||||
function dmrSynthPulse(type) {
|
||||
dmrLastEventTime = Date.now();
|
||||
|
||||
if (type === 'sync') {
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC);
|
||||
dmrEventType = 'sync';
|
||||
} else if (type === 'call') {
|
||||
dmrActivityTarget = DMR_BURST_CALL;
|
||||
dmrEventType = 'call';
|
||||
} else if (type === 'voice') {
|
||||
dmrActivityTarget = DMR_BURST_VOICE;
|
||||
dmrEventType = 'voice';
|
||||
} else if (type === 'slot' || type === 'nac') {
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
||||
} else if (type === 'raw') {
|
||||
// Any DSD output means the decoder is alive and processing
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
|
||||
if (dmrEventType === 'idle') dmrEventType = 'raw';
|
||||
}
|
||||
// keepalive and status don't change visuals
|
||||
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
|
||||
function updateDmrSynthStatus() {
|
||||
const el = document.getElementById('dmrSynthStatus');
|
||||
if (!el) return;
|
||||
|
||||
const labels = {
|
||||
stopped: 'STOPPED',
|
||||
idle: 'IDLE',
|
||||
raw: 'LISTENING',
|
||||
sync: 'SYNC',
|
||||
call: 'CALL',
|
||||
voice: 'VOICE'
|
||||
};
|
||||
const colors = {
|
||||
stopped: 'var(--text-muted)',
|
||||
idle: 'var(--text-muted)',
|
||||
raw: '#607d8b',
|
||||
sync: '#00e5ff',
|
||||
call: '#4caf50',
|
||||
voice: '#ff9800'
|
||||
};
|
||||
|
||||
el.textContent = labels[dmrEventType] || 'IDLE';
|
||||
el.style.color = colors[dmrEventType] || 'var(--text-muted)';
|
||||
}
|
||||
|
||||
function resizeDmrSynthesizer() {
|
||||
if (!dmrSynthCanvas) return;
|
||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
||||
if (rect.width > 0) {
|
||||
dmrSynthCanvas.width = Math.round(rect.width);
|
||||
dmrSynthCanvas.height = Math.round(rect.height) || 70;
|
||||
}
|
||||
}
|
||||
|
||||
function stopDmrSynthesizer() {
|
||||
if (dmrSynthAnimationId) {
|
||||
cancelAnimationFrame(dmrSynthAnimationId);
|
||||
dmrSynthAnimationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
||||
|
||||
// ============== AUDIO ==============
|
||||
|
||||
function startDmrAudio() {
|
||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||
if (!audioPlayer) return;
|
||||
const streamUrl = `/dmr/audio/stream?t=${Date.now()}`;
|
||||
audioPlayer.src = streamUrl;
|
||||
const volSlider = document.getElementById('dmrAudioVolume');
|
||||
if (volSlider) audioPlayer.volume = volSlider.value / 100;
|
||||
|
||||
audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING');
|
||||
audioPlayer.onerror = () => {
|
||||
// Retry if decoder is still running (stream may have dropped)
|
||||
if (isDmrRunning && dmrHasAudio) {
|
||||
console.warn('[DMR] Audio stream error, retrying in 2s...');
|
||||
updateDmrAudioStatus('RECONNECTING');
|
||||
setTimeout(() => {
|
||||
if (isDmrRunning && dmrHasAudio) startDmrAudio();
|
||||
}, 2000);
|
||||
} else {
|
||||
updateDmrAudioStatus('OFF');
|
||||
}
|
||||
};
|
||||
|
||||
audioPlayer.play().catch(e => {
|
||||
console.warn('[DMR] Audio autoplay blocked:', e);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Audio Ready', 'Click the page or interact to enable audio playback');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopDmrAudio() {
|
||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||
if (audioPlayer) {
|
||||
audioPlayer.pause();
|
||||
audioPlayer.src = '';
|
||||
}
|
||||
dmrHasAudio = false;
|
||||
}
|
||||
|
||||
function setDmrAudioVolume(value) {
|
||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||
if (audioPlayer) audioPlayer.volume = value / 100;
|
||||
}
|
||||
|
||||
function updateDmrAudioStatus(status) {
|
||||
const el = document.getElementById('dmrAudioStatus');
|
||||
if (!el) return;
|
||||
el.textContent = status;
|
||||
const colors = {
|
||||
'OFF': 'var(--text-muted)',
|
||||
'STREAMING': 'var(--accent-green)',
|
||||
'ERROR': 'var(--accent-red)',
|
||||
'UNAVAILABLE': 'var(--text-muted)',
|
||||
};
|
||||
el.style.color = colors[status] || 'var(--text-muted)';
|
||||
}
|
||||
|
||||
// ============== SETTINGS PERSISTENCE ==============
|
||||
|
||||
function restoreDmrSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem(DMR_SETTINGS_KEY);
|
||||
if (!saved) return;
|
||||
const s = JSON.parse(saved);
|
||||
const freqEl = document.getElementById('dmrFrequency');
|
||||
const protoEl = document.getElementById('dmrProtocol');
|
||||
const gainEl = document.getElementById('dmrGain');
|
||||
const ppmEl = document.getElementById('dmrPPM');
|
||||
const fineTuneEl = document.getElementById('dmrFineTune');
|
||||
const crcEl = document.getElementById('dmrRelaxCrc');
|
||||
const demodEl = document.getElementById('dmrDemod');
|
||||
if (freqEl && s.frequency != null) freqEl.value = s.frequency;
|
||||
if (protoEl && s.protocol) protoEl.value = s.protocol;
|
||||
if (gainEl && s.gain != null) gainEl.value = s.gain;
|
||||
if (ppmEl && s.ppm != null) ppmEl.value = s.ppm;
|
||||
if (fineTuneEl && s.fineTune != null) fineTuneEl.value = s.fineTune;
|
||||
if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc;
|
||||
if (demodEl && s.demod) demodEl.value = s.demod;
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
// ============== BOOKMARKS ==============
|
||||
|
||||
function loadDmrBookmarks() {
|
||||
try {
|
||||
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
|
||||
dmrBookmarks = saved ? JSON.parse(saved) : [];
|
||||
} catch (e) {
|
||||
dmrBookmarks = [];
|
||||
}
|
||||
renderDmrBookmarks();
|
||||
}
|
||||
|
||||
function saveDmrBookmarks() {
|
||||
try {
|
||||
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
function addDmrBookmark() {
|
||||
const freqInput = document.getElementById('dmrBookmarkFreq');
|
||||
const labelInput = document.getElementById('dmrBookmarkLabel');
|
||||
if (!freqInput) return;
|
||||
|
||||
const freq = parseFloat(freqInput.value);
|
||||
if (isNaN(freq) || freq <= 0) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Invalid Frequency', 'Enter a valid frequency');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
||||
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
|
||||
|
||||
// Duplicate check
|
||||
if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Duplicate', 'This frequency/protocol is already bookmarked');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() });
|
||||
saveDmrBookmarks();
|
||||
renderDmrBookmarks();
|
||||
freqInput.value = '';
|
||||
if (labelInput) labelInput.value = '';
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`);
|
||||
}
|
||||
}
|
||||
|
||||
function addCurrentDmrFreqBookmark() {
|
||||
const freqEl = document.getElementById('dmrFrequency');
|
||||
const freqInput = document.getElementById('dmrBookmarkFreq');
|
||||
if (freqEl && freqInput) {
|
||||
freqInput.value = freqEl.value;
|
||||
}
|
||||
addDmrBookmark();
|
||||
}
|
||||
|
||||
function removeDmrBookmark(index) {
|
||||
dmrBookmarks.splice(index, 1);
|
||||
saveDmrBookmarks();
|
||||
renderDmrBookmarks();
|
||||
}
|
||||
|
||||
function dmrQuickTune(freq, protocol) {
|
||||
const freqEl = document.getElementById('dmrFrequency');
|
||||
const protoEl = document.getElementById('dmrProtocol');
|
||||
if (freqEl) freqEl.value = freq;
|
||||
if (protoEl) protoEl.value = protocol;
|
||||
}
|
||||
|
||||
function renderDmrBookmarks() {
|
||||
const container = document.getElementById('dmrBookmarksList');
|
||||
if (!container) return;
|
||||
|
||||
if (dmrBookmarks.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dmrBookmarks.map((b, i) => `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 3px; margin-bottom: 3px;">
|
||||
<span style="cursor: pointer; color: var(--accent-cyan); font-size: 11px; flex: 1;" onclick="dmrQuickTune(${b.freq}, '${b.protocol}')" title="${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})">${b.label}</span>
|
||||
<span style="color: var(--text-muted); font-size: 9px; margin: 0 6px;">${b.protocol.toUpperCase()}</span>
|
||||
<button onclick="removeDmrBookmark(${i})" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 0 4px;">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== STATUS SYNC ==============
|
||||
|
||||
function checkDmrStatus() {
|
||||
fetch('/dmr/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running && !isDmrRunning) {
|
||||
// Backend is running but frontend lost track — resync
|
||||
isDmrRunning = true;
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
} else if (!data.running && isDmrRunning) {
|
||||
// Backend stopped but frontend didn't know
|
||||
isDmrRunning = false;
|
||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== INIT ==============
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
restoreDmrSettings();
|
||||
loadDmrBookmarks();
|
||||
});
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.startDmr = startDmr;
|
||||
window.stopDmr = stopDmr;
|
||||
window.checkDmrTools = checkDmrTools;
|
||||
window.checkDmrStatus = checkDmrStatus;
|
||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||
window.setDmrAudioVolume = setDmrAudioVolume;
|
||||
window.addDmrBookmark = addDmrBookmark;
|
||||
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
|
||||
window.removeDmrBookmark = removeDmrBookmark;
|
||||
window.dmrQuickTune = dmrQuickTune;
|
||||
window.sweepDmrFineTune = sweepDmrFineTune;
|
||||
+4176
-2599
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,7 @@ const Meshtastic = (function() {
|
||||
/**
|
||||
* Initialize the Leaflet map
|
||||
*/
|
||||
function initMap() {
|
||||
async function initMap() {
|
||||
if (meshMap) return;
|
||||
|
||||
const mapContainer = document.getElementById('meshMap');
|
||||
@@ -111,16 +111,19 @@ const Meshtastic = (function() {
|
||||
window.meshMap = meshMap;
|
||||
|
||||
// Use settings manager for tile layer (allows runtime changes)
|
||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
if (typeof Settings !== 'undefined') {
|
||||
// Wait for settings to load from server before applying tiles
|
||||
await Settings.init();
|
||||
Settings.createTileLayer().addTo(meshMap);
|
||||
Settings.registerMap(meshMap);
|
||||
} else {
|
||||
L.tileLayer('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>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
}).addTo(meshMap);
|
||||
}
|
||||
L.tileLayer('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>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(meshMap);
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
setTimeout(() => {
|
||||
@@ -143,7 +146,7 @@ const Meshtastic = (function() {
|
||||
|
||||
if (data.running) {
|
||||
isConnected = true;
|
||||
updateConnectionUI(true, data.device);
|
||||
updateConnectionUI(true, data.device, data.connection_type);
|
||||
if (data.node_info) {
|
||||
updateNodeInfo(data.node_info);
|
||||
localNodeId = data.node_info.num;
|
||||
@@ -158,21 +161,64 @@ const Meshtastic = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection type change (serial vs TCP)
|
||||
*/
|
||||
function onConnectionTypeChange() {
|
||||
const connTypeSelect = document.getElementById('meshStripConnType');
|
||||
const deviceSelect = document.getElementById('meshStripDevice');
|
||||
const hostnameInput = document.getElementById('meshStripHostname');
|
||||
|
||||
if (!connTypeSelect) return;
|
||||
|
||||
const connType = connTypeSelect.value;
|
||||
|
||||
if (connType === 'tcp') {
|
||||
// Show hostname input, hide device select
|
||||
if (deviceSelect) deviceSelect.style.display = 'none';
|
||||
if (hostnameInput) hostnameInput.style.display = 'block';
|
||||
} else {
|
||||
// Show device select, hide hostname input
|
||||
if (deviceSelect) deviceSelect.style.display = 'block';
|
||||
if (hostnameInput) hostnameInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Meshtastic connection
|
||||
*/
|
||||
async function start() {
|
||||
// Try strip device select first, then sidebar
|
||||
const stripDeviceSelect = document.getElementById('meshStripDevice');
|
||||
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
|
||||
let device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
|
||||
// Get connection type
|
||||
const connTypeSelect = document.getElementById('meshStripConnType');
|
||||
const connectionType = connTypeSelect?.value || 'serial';
|
||||
|
||||
// Check if auto-detect is selected but multiple ports exist
|
||||
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) {
|
||||
// Multiple ports available - prompt user to select one
|
||||
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
|
||||
updateStatusIndicator('disconnected', 'Select a device');
|
||||
return;
|
||||
// Get connection parameters based on type
|
||||
let device = null;
|
||||
let hostname = null;
|
||||
|
||||
if (connectionType === 'tcp') {
|
||||
// TCP connection - get hostname
|
||||
const hostnameInput = document.getElementById('meshStripHostname');
|
||||
hostname = hostnameInput?.value?.trim() || null;
|
||||
|
||||
if (!hostname) {
|
||||
showStatusMessage('Please enter a hostname or IP address for TCP connection', 'error');
|
||||
updateStatusIndicator('disconnected', 'Enter hostname');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Serial connection - get device
|
||||
const stripDeviceSelect = document.getElementById('meshStripDevice');
|
||||
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
|
||||
device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
|
||||
|
||||
// Check if auto-detect is selected but multiple ports exist
|
||||
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) {
|
||||
// Multiple ports available - prompt user to select one
|
||||
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
|
||||
updateStatusIndicator('disconnected', 'Select a device');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusIndicator('connecting', 'Connecting...');
|
||||
@@ -184,17 +230,27 @@ const Meshtastic = (function() {
|
||||
if (stripStatus) stripStatus.textContent = 'Connecting...';
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
connection_type: connectionType
|
||||
};
|
||||
|
||||
if (connectionType === 'tcp') {
|
||||
requestBody.hostname = hostname;
|
||||
} else if (device) {
|
||||
requestBody.device = device;
|
||||
}
|
||||
|
||||
const response = await fetch('/meshtastic/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device: device || undefined })
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
isConnected = true;
|
||||
updateConnectionUI(true, data.device);
|
||||
updateConnectionUI(true, data.device, data.connection_type);
|
||||
if (data.node_info) {
|
||||
updateNodeInfo(data.node_info);
|
||||
localNodeId = data.node_info.num;
|
||||
@@ -202,7 +258,8 @@ const Meshtastic = (function() {
|
||||
loadChannels();
|
||||
loadNodes();
|
||||
startStream();
|
||||
showNotification('Meshtastic', 'Connected to device');
|
||||
const connLabel = data.connection_type === 'tcp' ? 'TCP' : 'Serial';
|
||||
showNotification('Meshtastic', `Connected via ${connLabel}`);
|
||||
} else {
|
||||
updateStatusIndicator('disconnected', data.message || 'Connection failed');
|
||||
showStatusMessage(data.message || 'Failed to connect', 'error');
|
||||
@@ -232,7 +289,7 @@ const Meshtastic = (function() {
|
||||
/**
|
||||
* Update connection UI state
|
||||
*/
|
||||
function updateConnectionUI(connected, device) {
|
||||
function updateConnectionUI(connected, device, connectionType) {
|
||||
const connectBtn = document.getElementById('meshConnectBtn');
|
||||
const disconnectBtn = document.getElementById('meshDisconnectBtn');
|
||||
const nodeSection = document.getElementById('meshNodeSection');
|
||||
@@ -248,7 +305,9 @@ const Meshtastic = (function() {
|
||||
const stripStatus = document.getElementById('meshStripStatus');
|
||||
|
||||
if (connected) {
|
||||
updateStatusIndicator('connected', device ? `Connected to ${device}` : 'Connected');
|
||||
const connLabel = connectionType === 'tcp' ? 'TCP' : 'Serial';
|
||||
const statusText = device ? `${device} (${connLabel})` : `Connected (${connLabel})`;
|
||||
updateStatusIndicator('connected', statusText);
|
||||
if (connectBtn) connectBtn.style.display = 'none';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'block';
|
||||
if (nodeSection) nodeSection.style.display = 'block';
|
||||
@@ -263,7 +322,7 @@ const Meshtastic = (function() {
|
||||
if (stripDot) {
|
||||
stripDot.className = 'mesh-strip-dot connected';
|
||||
}
|
||||
if (stripStatus) stripStatus.textContent = device || 'Connected';
|
||||
if (stripStatus) stripStatus.textContent = statusText;
|
||||
} else {
|
||||
updateStatusIndicator('disconnected', 'Disconnected');
|
||||
if (connectBtn) connectBtn.style.display = 'block';
|
||||
@@ -2200,6 +2259,7 @@ const Meshtastic = (function() {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
onConnectionTypeChange,
|
||||
loadPorts,
|
||||
refreshChannels,
|
||||
openChannelModal,
|
||||
|
||||
@@ -84,7 +84,7 @@ const SpyStations = (function() {
|
||||
modeContainer.innerHTML = modes.map(m => `
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span>
|
||||
<span style="font-family: 'Space Mono', monospace; font-size: 10px;">${m}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* SSTV General Mode
|
||||
* Terrestrial Slow-Scan Television decoder interface
|
||||
*/
|
||||
|
||||
const SSTVGeneral = (function() {
|
||||
// State
|
||||
let isRunning = false;
|
||||
let eventSource = null;
|
||||
let images = [];
|
||||
let currentMode = null;
|
||||
let progress = 0;
|
||||
|
||||
// Signal scope state
|
||||
let sstvGeneralScopeCtx = null;
|
||||
let sstvGeneralScopeAnim = null;
|
||||
let sstvGeneralScopeHistory = [];
|
||||
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||
let sstvGeneralScopeRms = 0;
|
||||
let sstvGeneralScopePeak = 0;
|
||||
let sstvGeneralScopeTargetRms = 0;
|
||||
let sstvGeneralScopeTargetPeak = 0;
|
||||
let sstvGeneralScopeMsgBurst = 0;
|
||||
let sstvGeneralScopeTone = null;
|
||||
|
||||
/**
|
||||
* Initialize the SSTV General mode
|
||||
*/
|
||||
function init() {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a preset frequency from the dropdown
|
||||
*/
|
||||
function selectPreset(value) {
|
||||
if (!value) return;
|
||||
|
||||
const parts = value.split('|');
|
||||
const freq = parseFloat(parts[0]);
|
||||
const mod = parts[1];
|
||||
|
||||
const freqInput = document.getElementById('sstvGeneralFrequency');
|
||||
const modSelect = document.getElementById('sstvGeneralModulation');
|
||||
|
||||
if (freqInput) freqInput.value = freq;
|
||||
if (modSelect) modSelect.value = mod;
|
||||
|
||||
// Update strip display
|
||||
const stripFreq = document.getElementById('sstvGeneralStripFreq');
|
||||
const stripMod = document.getElementById('sstvGeneralStripMod');
|
||||
if (stripFreq) stripFreq.textContent = freq.toFixed(3);
|
||||
if (stripMod) stripMod.textContent = mod.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current decoder status
|
||||
*/
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const response = await fetch('/sstv-general/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.available) {
|
||||
updateStatusUI('unavailable', 'Decoder not installed');
|
||||
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.running) {
|
||||
isRunning = true;
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
startStream();
|
||||
} else {
|
||||
updateStatusUI('idle', 'Idle');
|
||||
}
|
||||
|
||||
updateImageCount(data.image_count || 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to check SSTV General status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSTV decoder
|
||||
*/
|
||||
async function start() {
|
||||
const freqInput = document.getElementById('sstvGeneralFrequency');
|
||||
const modSelect = document.getElementById('sstvGeneralModulation');
|
||||
const deviceSelect = document.getElementById('deviceSelect');
|
||||
|
||||
const frequency = parseFloat(freqInput?.value || '14.230');
|
||||
const modulation = modSelect?.value || 'usb';
|
||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||
|
||||
updateStatusUI('connecting', 'Starting...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/sstv-general/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frequency, modulation, device })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
isRunning = true;
|
||||
updateStatusUI('listening', `${frequency} MHz ${modulation.toUpperCase()}`);
|
||||
startStream();
|
||||
showNotification('SSTV', `Listening on ${frequency} MHz ${modulation.toUpperCase()}`);
|
||||
|
||||
// Update strip
|
||||
const stripFreq = document.getElementById('sstvGeneralStripFreq');
|
||||
const stripMod = document.getElementById('sstvGeneralStripMod');
|
||||
if (stripFreq) stripFreq.textContent = frequency.toFixed(3);
|
||||
if (stripMod) stripMod.textContent = modulation.toUpperCase();
|
||||
} else {
|
||||
updateStatusUI('idle', 'Start failed');
|
||||
showStatusMessage(data.message || 'Failed to start decoder', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start SSTV General:', err);
|
||||
updateStatusUI('idle', 'Error');
|
||||
showStatusMessage('Connection error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop SSTV decoder
|
||||
*/
|
||||
async function stop() {
|
||||
try {
|
||||
await fetch('/sstv-general/stop', { method: 'POST' });
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopped');
|
||||
showNotification('SSTV', 'Decoder stopped');
|
||||
} catch (err) {
|
||||
console.error('Failed to stop SSTV General:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status UI elements
|
||||
*/
|
||||
function updateStatusUI(status, text) {
|
||||
const dot = document.getElementById('sstvGeneralStripDot');
|
||||
const statusText = document.getElementById('sstvGeneralStripStatus');
|
||||
const startBtn = document.getElementById('sstvGeneralStartBtn');
|
||||
const stopBtn = document.getElementById('sstvGeneralStopBtn');
|
||||
|
||||
if (dot) {
|
||||
dot.className = 'sstv-general-strip-dot';
|
||||
if (status === 'listening' || status === 'detecting') {
|
||||
dot.classList.add('listening');
|
||||
} else if (status === 'decoding') {
|
||||
dot.classList.add('decoding');
|
||||
} else {
|
||||
dot.classList.add('idle');
|
||||
}
|
||||
}
|
||||
|
||||
if (statusText) {
|
||||
statusText.textContent = text || status;
|
||||
}
|
||||
|
||||
if (startBtn && stopBtn) {
|
||||
if (status === 'listening' || status === 'decoding') {
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
startBtn.style.display = 'inline-block';
|
||||
stopBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update live content area
|
||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||
if (liveContent) {
|
||||
if (status === 'idle' || status === 'unavailable') {
|
||||
liveContent.innerHTML = renderIdleState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render idle state HTML
|
||||
*/
|
||||
function renderIdleState() {
|
||||
return `
|
||||
<div class="sstv-general-idle-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
|
||||
</svg>
|
||||
<h4>SSTV Decoder</h4>
|
||||
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize signal scope canvas
|
||||
*/
|
||||
function initSstvGeneralScope() {
|
||||
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||
sstvGeneralScopeRms = 0;
|
||||
sstvGeneralScopePeak = 0;
|
||||
sstvGeneralScopeTargetRms = 0;
|
||||
sstvGeneralScopeTargetPeak = 0;
|
||||
sstvGeneralScopeMsgBurst = 0;
|
||||
sstvGeneralScopeTone = null;
|
||||
drawSstvGeneralScope();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw signal scope animation frame
|
||||
*/
|
||||
function drawSstvGeneralScope() {
|
||||
const ctx = sstvGeneralScopeCtx;
|
||||
if (!ctx) return;
|
||||
const W = ctx.canvas.width;
|
||||
const H = ctx.canvas.height;
|
||||
const midY = H / 2;
|
||||
|
||||
// Phosphor persistence
|
||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Smooth towards target
|
||||
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
|
||||
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
|
||||
|
||||
// Push to history
|
||||
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
|
||||
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = (H / 4) * i;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
}
|
||||
for (let i = 1; i < 8; i++) {
|
||||
const x = (W / 8) * i;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||
}
|
||||
|
||||
// Waveform
|
||||
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||
ctx.strokeStyle = '#c080ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = '#c080ff';
|
||||
ctx.shadowBlur = 4;
|
||||
|
||||
// Upper half
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||
const y = midY - amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Lower half (mirror)
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||
const y = midY + amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Peak indicator
|
||||
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
|
||||
if (peakNorm > 0.01) {
|
||||
const peakY = midY - peakNorm * midY * 0.9;
|
||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Image decode flash
|
||||
if (sstvGeneralScopeMsgBurst > 0.01) {
|
||||
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
sstvGeneralScopeMsgBurst *= 0.88;
|
||||
}
|
||||
|
||||
// Update labels
|
||||
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
|
||||
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
|
||||
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
|
||||
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
|
||||
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
|
||||
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
|
||||
if (toneLabel) {
|
||||
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||
}
|
||||
if (statusLabel) {
|
||||
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||
}
|
||||
|
||||
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop signal scope
|
||||
*/
|
||||
function stopSstvGeneralScope() {
|
||||
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||
sstvGeneralScopeCtx = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSE stream
|
||||
*/
|
||||
function startStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Show and init scope
|
||||
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'block';
|
||||
initSstvGeneralScope();
|
||||
|
||||
eventSource = new EventSource('/sstv-general/stream');
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'sstv_progress') {
|
||||
handleProgress(data);
|
||||
} else if (data.type === 'sstv_scope') {
|
||||
sstvGeneralScopeTargetRms = data.rms;
|
||||
sstvGeneralScopeTargetPeak = data.peak;
|
||||
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.warn('SSTV General SSE error, will reconnect...');
|
||||
setTimeout(() => {
|
||||
if (isRunning) startStream();
|
||||
}, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop SSE stream
|
||||
*/
|
||||
function stopStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopSstvGeneralScope();
|
||||
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle progress update
|
||||
*/
|
||||
function handleProgress(data) {
|
||||
currentMode = data.mode || currentMode;
|
||||
progress = data.progress || 0;
|
||||
|
||||
if (data.status === 'decoding') {
|
||||
updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`);
|
||||
renderDecodeProgress(data);
|
||||
} else if (data.status === 'complete' && data.image) {
|
||||
images.unshift(data.image);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
showNotification('SSTV', 'New image decoded!');
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
sstvGeneralScopeMsgBurst = 1.0;
|
||||
// Clear decode progress so signal monitor can take over
|
||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||
if (liveContent) liveContent.innerHTML = '';
|
||||
} else if (data.status === 'detecting') {
|
||||
// Ignore detecting events if currently decoding (e.g. Doppler updates)
|
||||
const dot = document.getElementById('sstvGeneralStripDot');
|
||||
if (dot && dot.classList.contains('decoding')) return;
|
||||
|
||||
updateStatusUI('listening', data.message || 'Listening...');
|
||||
if (data.signal_level !== undefined) {
|
||||
renderSignalMonitor(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render signal monitor in live area during detecting mode
|
||||
*/
|
||||
function renderSignalMonitor(data) {
|
||||
const container = document.getElementById('sstvGeneralLiveContent');
|
||||
if (!container) return;
|
||||
|
||||
const level = data.signal_level || 0;
|
||||
const tone = data.sstv_tone;
|
||||
|
||||
let barColor, statusText;
|
||||
if (tone === 'leader') {
|
||||
barColor = 'var(--accent-green)';
|
||||
statusText = 'SSTV leader tone detected';
|
||||
} else if (tone === 'sync') {
|
||||
barColor = 'var(--accent-cyan)';
|
||||
statusText = 'SSTV sync pulse detected';
|
||||
} else if (tone === 'noise') {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else if (level > 10) {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'No signal';
|
||||
}
|
||||
|
||||
let monitor = container.querySelector('.sstv-general-signal-monitor');
|
||||
if (!monitor) {
|
||||
container.innerHTML = `
|
||||
<div class="sstv-general-signal-monitor">
|
||||
<div class="sstv-general-signal-monitor-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
|
||||
<circle cx="12" cy="18" r="2"/>
|
||||
<path d="M12 16V12"/>
|
||||
</svg>
|
||||
Signal Monitor
|
||||
</div>
|
||||
<div class="sstv-general-signal-level-row">
|
||||
<span class="sstv-general-signal-level-label">LEVEL</span>
|
||||
<div class="sstv-general-signal-bar-track">
|
||||
<div class="sstv-general-signal-bar-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="sstv-general-signal-level-value">0</span>
|
||||
</div>
|
||||
<div class="sstv-general-signal-status-text">No signal</div>
|
||||
<div class="sstv-general-signal-vis-state">VIS: idle</div>
|
||||
</div>`;
|
||||
monitor = container.querySelector('.sstv-general-signal-monitor');
|
||||
}
|
||||
|
||||
const fill = monitor.querySelector('.sstv-general-signal-bar-fill');
|
||||
fill.style.width = level + '%';
|
||||
fill.style.background = barColor;
|
||||
monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText;
|
||||
monitor.querySelector('.sstv-general-signal-level-value').textContent = level;
|
||||
|
||||
const visStateEl = monitor.querySelector('.sstv-general-signal-vis-state');
|
||||
if (visStateEl && data.vis_state) {
|
||||
const stateLabels = {
|
||||
'idle': 'Idle',
|
||||
'leader_1': 'Leader',
|
||||
'break': 'Break',
|
||||
'leader_2': 'Leader 2',
|
||||
'start_bit': 'Start bit',
|
||||
'data_bits': 'Data bits',
|
||||
'parity': 'Parity',
|
||||
'stop_bit': 'Stop bit',
|
||||
};
|
||||
const label = stateLabels[data.vis_state] || data.vis_state;
|
||||
visStateEl.textContent = 'VIS: ' + label;
|
||||
visStateEl.className = 'sstv-general-signal-vis-state' +
|
||||
(data.vis_state !== 'idle' ? ' active' : '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render decode progress in live area
|
||||
*/
|
||||
function renderDecodeProgress(data) {
|
||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||
if (!liveContent) return;
|
||||
|
||||
let container = liveContent.querySelector('.sstv-general-decode-container');
|
||||
if (!container) {
|
||||
liveContent.innerHTML = `
|
||||
<div class="sstv-general-decode-container">
|
||||
<div class="sstv-general-canvas-container">
|
||||
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
|
||||
</div>
|
||||
<div class="sstv-general-decode-info">
|
||||
<div class="sstv-general-mode-label"></div>
|
||||
<div class="sstv-general-progress-bar">
|
||||
<div class="progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="sstv-general-status-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container = liveContent.querySelector('.sstv-general-decode-container');
|
||||
}
|
||||
|
||||
container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...';
|
||||
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
|
||||
container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...';
|
||||
|
||||
if (data.partial_image) {
|
||||
const img = container.querySelector('#sstvGeneralDecodeImg');
|
||||
if (img) img.src = data.partial_image;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load decoded images
|
||||
*/
|
||||
async function loadImages() {
|
||||
try {
|
||||
const response = await fetch('/sstv-general/images');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
images = data.images || [];
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load SSTV General images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image count display
|
||||
*/
|
||||
function updateImageCount(count) {
|
||||
const countEl = document.getElementById('sstvGeneralImageCount');
|
||||
const stripCount = document.getElementById('sstvGeneralStripImageCount');
|
||||
|
||||
if (countEl) countEl.textContent = count;
|
||||
if (stripCount) stripCount.textContent = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render image gallery
|
||||
*/
|
||||
function renderGallery() {
|
||||
const gallery = document.getElementById('sstvGeneralGallery');
|
||||
if (!gallery) return;
|
||||
|
||||
if (images.length === 0) {
|
||||
gallery.innerHTML = `
|
||||
<div class="sstv-general-gallery-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
<p>No images decoded yet</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
gallery.innerHTML = images.map(img => `
|
||||
<div class="sstv-general-image-card">
|
||||
<div class="sstv-general-image-card-inner" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
|
||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
|
||||
</div>
|
||||
<div class="sstv-general-image-info">
|
||||
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||
</div>
|
||||
<div class="sstv-general-image-actions">
|
||||
<button onclick="event.stopPropagation(); SSTVGeneral.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); SSTVGeneral.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show full-size image in modal
|
||||
*/
|
||||
let currentModalUrl = null;
|
||||
let currentModalFilename = null;
|
||||
|
||||
function showImage(url, filename) {
|
||||
currentModalUrl = url;
|
||||
currentModalFilename = filename || null;
|
||||
|
||||
let modal = document.getElementById('sstvGeneralImageModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'sstvGeneralImageModal';
|
||||
modal.className = 'sstv-general-image-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="sstv-general-modal-toolbar">
|
||||
<button class="sstv-general-modal-btn" id="sstvGeneralModalDownload" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download
|
||||
</button>
|
||||
<button class="sstv-general-modal-btn delete" id="sstvGeneralModalDelete" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">×</button>
|
||||
<img src="" alt="SSTV Image">
|
||||
`;
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeImage();
|
||||
});
|
||||
modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => {
|
||||
if (currentModalUrl && currentModalFilename) {
|
||||
downloadImage(currentModalUrl, currentModalFilename);
|
||||
}
|
||||
});
|
||||
modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => {
|
||||
if (currentModalFilename) {
|
||||
deleteImage(currentModalFilename);
|
||||
}
|
||||
});
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
modal.querySelector('img').src = url;
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close image modal
|
||||
*/
|
||||
function closeImage() {
|
||||
const modal = document.getElementById('sstvGeneralImageModal');
|
||||
if (modal) modal.classList.remove('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
*/
|
||||
function formatTimestamp(isoString) {
|
||||
if (!isoString) return '--';
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe display
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single image
|
||||
*/
|
||||
async function deleteImage(filename) {
|
||||
if (!confirm('Delete this image?')) return;
|
||||
try {
|
||||
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = images.filter(img => img.filename !== filename);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
closeImage();
|
||||
showNotification('SSTV', 'Image deleted');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete image:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all images
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (!confirm('Delete all decoded images?')) return;
|
||||
try {
|
||||
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = [];
|
||||
updateImageCount(0);
|
||||
renderGallery();
|
||||
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image
|
||||
*/
|
||||
function downloadImage(url, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url + '/download';
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status message
|
||||
*/
|
||||
function showStatusMessage(message, type) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('SSTV', message);
|
||||
} else {
|
||||
console.log(`[SSTV General ${type}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
loadImages,
|
||||
showImage,
|
||||
closeImage,
|
||||
deleteImage,
|
||||
deleteAllImages,
|
||||
downloadImage,
|
||||
selectPreset
|
||||
};
|
||||
})();
|
||||
+399
-24
@@ -21,6 +21,18 @@ const SSTV = (function() {
|
||||
// ISS frequency
|
||||
const ISS_FREQ = 145.800;
|
||||
|
||||
// Signal scope state
|
||||
let sstvScopeCtx = null;
|
||||
let sstvScopeAnim = null;
|
||||
let sstvScopeHistory = [];
|
||||
const SSTV_SCOPE_LEN = 200;
|
||||
let sstvScopeRms = 0;
|
||||
let sstvScopePeak = 0;
|
||||
let sstvScopeTargetRms = 0;
|
||||
let sstvScopeTargetPeak = 0;
|
||||
let sstvScopeMsgBurst = 0;
|
||||
let sstvScopeTone = null;
|
||||
|
||||
/**
|
||||
* Initialize the SSTV mode
|
||||
*/
|
||||
@@ -41,8 +53,13 @@ const SSTV = (function() {
|
||||
const latInput = document.getElementById('sstvObsLat');
|
||||
const lonInput = document.getElementById('sstvObsLon');
|
||||
|
||||
const storedLat = localStorage.getItem('observerLat');
|
||||
const storedLon = localStorage.getItem('observerLon');
|
||||
let storedLat = localStorage.getItem('observerLat');
|
||||
let storedLon = localStorage.getItem('observerLon');
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
storedLat = shared.lat.toString();
|
||||
storedLon = shared.lon.toString();
|
||||
}
|
||||
|
||||
if (latInput && storedLat) latInput.value = storedLat;
|
||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||
@@ -64,8 +81,12 @@ const SSTV = (function() {
|
||||
|
||||
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
|
||||
!isNaN(lon) && lon >= -180 && lon <= 180) {
|
||||
localStorage.setItem('observerLat', lat.toString());
|
||||
localStorage.setItem('observerLon', lon.toString());
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
ObserverLocation.setShared({ lat, lon });
|
||||
} else {
|
||||
localStorage.setItem('observerLat', lat.toString());
|
||||
localStorage.setItem('observerLon', lon.toString());
|
||||
}
|
||||
loadIssSchedule(); // Refresh pass predictions
|
||||
}
|
||||
}
|
||||
@@ -94,8 +115,12 @@ const SSTV = (function() {
|
||||
if (latInput) latInput.value = lat;
|
||||
if (lonInput) lonInput.value = lon;
|
||||
|
||||
localStorage.setItem('observerLat', lat);
|
||||
localStorage.setItem('observerLon', lon);
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||
} else {
|
||||
localStorage.setItem('observerLat', lat);
|
||||
localStorage.setItem('observerLon', lon);
|
||||
}
|
||||
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
@@ -146,7 +171,7 @@ const SSTV = (function() {
|
||||
/**
|
||||
* Initialize Leaflet map for ISS tracking
|
||||
*/
|
||||
function initMap() {
|
||||
async function initMap() {
|
||||
const mapContainer = document.getElementById('sstvIssMap');
|
||||
if (!mapContainer || issMap) return;
|
||||
|
||||
@@ -160,14 +185,19 @@ const SSTV = (function() {
|
||||
attributionControl: false,
|
||||
worldCopyJump: true
|
||||
});
|
||||
window.issMap = issMap;
|
||||
|
||||
// Add tile layer using settings manager if available
|
||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
if (typeof Settings !== 'undefined') {
|
||||
// Wait for settings to load from server before applying tiles
|
||||
await Settings.init();
|
||||
Settings.createTileLayer().addTo(issMap);
|
||||
Settings.registerMap(issMap);
|
||||
} else {
|
||||
// Fallback to dark theme tiles
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19
|
||||
maxZoom: 19,
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(issMap);
|
||||
}
|
||||
|
||||
@@ -473,7 +503,7 @@ const SSTV = (function() {
|
||||
|
||||
if (!data.available) {
|
||||
updateStatusUI('unavailable', 'Decoder not installed');
|
||||
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
|
||||
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -503,6 +533,11 @@ const SSTV = (function() {
|
||||
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
|
||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||
|
||||
// Check if device is available
|
||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('sstv')) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusUI('connecting', 'Starting...');
|
||||
|
||||
try {
|
||||
@@ -516,6 +551,9 @@ const SSTV = (function() {
|
||||
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
isRunning = true;
|
||||
if (typeof reserveDevice === 'function') {
|
||||
reserveDevice(device, 'sstv');
|
||||
}
|
||||
updateStatusUI('listening', `${frequency} MHz`);
|
||||
startStream();
|
||||
showNotification('SSTV', `Listening on ${frequency} MHz`);
|
||||
@@ -537,6 +575,9 @@ const SSTV = (function() {
|
||||
try {
|
||||
await fetch('/sstv/stop', { method: 'POST' });
|
||||
isRunning = false;
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice('sstv');
|
||||
}
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopped');
|
||||
showNotification('SSTV', 'Decoder stopped');
|
||||
@@ -605,6 +646,136 @@ const SSTV = (function() {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize signal scope canvas
|
||||
*/
|
||||
function initSstvScope() {
|
||||
const canvas = document.getElementById('sstvScopeCanvas');
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||
sstvScopeCtx = canvas.getContext('2d');
|
||||
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
|
||||
sstvScopeRms = 0;
|
||||
sstvScopePeak = 0;
|
||||
sstvScopeTargetRms = 0;
|
||||
sstvScopeTargetPeak = 0;
|
||||
sstvScopeMsgBurst = 0;
|
||||
sstvScopeTone = null;
|
||||
drawSstvScope();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw signal scope animation frame
|
||||
*/
|
||||
function drawSstvScope() {
|
||||
const ctx = sstvScopeCtx;
|
||||
if (!ctx) return;
|
||||
const W = ctx.canvas.width;
|
||||
const H = ctx.canvas.height;
|
||||
const midY = H / 2;
|
||||
|
||||
// Phosphor persistence
|
||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Smooth towards target
|
||||
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
|
||||
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
|
||||
|
||||
// Push to history
|
||||
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
|
||||
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = (H / 4) * i;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
}
|
||||
for (let i = 1; i < 8; i++) {
|
||||
const x = (W / 8) * i;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||
}
|
||||
|
||||
// Waveform
|
||||
const stepX = W / (SSTV_SCOPE_LEN - 1);
|
||||
ctx.strokeStyle = '#c080ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = '#c080ff';
|
||||
ctx.shadowBlur = 4;
|
||||
|
||||
// Upper half
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||
const y = midY - amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Lower half (mirror)
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||
const y = midY + amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Peak indicator
|
||||
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
|
||||
if (peakNorm > 0.01) {
|
||||
const peakY = midY - peakNorm * midY * 0.9;
|
||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Image decode flash
|
||||
if (sstvScopeMsgBurst > 0.01) {
|
||||
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
sstvScopeMsgBurst *= 0.88;
|
||||
}
|
||||
|
||||
// Update labels
|
||||
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
|
||||
const peakLabel = document.getElementById('sstvScopePeakLabel');
|
||||
const toneLabel = document.getElementById('sstvScopeToneLabel');
|
||||
const statusLabel = document.getElementById('sstvScopeStatusLabel');
|
||||
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
|
||||
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
|
||||
if (toneLabel) {
|
||||
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||
}
|
||||
if (statusLabel) {
|
||||
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||
}
|
||||
|
||||
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop signal scope
|
||||
*/
|
||||
function stopSstvScope() {
|
||||
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
|
||||
sstvScopeCtx = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSE stream
|
||||
*/
|
||||
@@ -613,6 +784,11 @@ const SSTV = (function() {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Show and init scope
|
||||
const scopePanel = document.getElementById('sstvScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'block';
|
||||
initSstvScope();
|
||||
|
||||
eventSource = new EventSource('/sstv/stream');
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
@@ -620,6 +796,10 @@ const SSTV = (function() {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'sstv_progress') {
|
||||
handleProgress(data);
|
||||
} else if (data.type === 'sstv_scope') {
|
||||
sstvScopeTargetRms = data.rms;
|
||||
sstvScopeTargetPeak = data.peak;
|
||||
if (data.tone !== undefined) sstvScopeTone = data.tone;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err);
|
||||
@@ -642,6 +822,9 @@ const SSTV = (function() {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopSstvScope();
|
||||
const scopePanel = document.getElementById('sstvScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -662,8 +845,97 @@ const SSTV = (function() {
|
||||
renderGallery();
|
||||
showNotification('SSTV', 'New image decoded!');
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
sstvScopeMsgBurst = 1.0;
|
||||
// Clear decode progress so signal monitor can take over
|
||||
const liveContent = document.getElementById('sstvLiveContent');
|
||||
if (liveContent) liveContent.innerHTML = '';
|
||||
} else if (data.status === 'detecting') {
|
||||
// Ignore detecting events if currently decoding (e.g. Doppler updates)
|
||||
const dot = document.getElementById('sstvStripDot');
|
||||
if (dot && dot.classList.contains('decoding')) return;
|
||||
|
||||
updateStatusUI('listening', data.message || 'Listening...');
|
||||
if (data.signal_level !== undefined) {
|
||||
renderSignalMonitor(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render signal monitor in live area during detecting mode
|
||||
*/
|
||||
function renderSignalMonitor(data) {
|
||||
const container = document.getElementById('sstvLiveContent');
|
||||
if (!container) return;
|
||||
|
||||
const level = data.signal_level || 0;
|
||||
const tone = data.sstv_tone;
|
||||
|
||||
let barColor, statusText;
|
||||
if (tone === 'leader') {
|
||||
barColor = 'var(--accent-green)';
|
||||
statusText = 'SSTV leader tone detected';
|
||||
} else if (tone === 'sync') {
|
||||
barColor = 'var(--accent-cyan)';
|
||||
statusText = 'SSTV sync pulse detected';
|
||||
} else if (tone === 'noise') {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else if (level > 10) {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'Audio signal present';
|
||||
} else {
|
||||
barColor = 'var(--text-dim)';
|
||||
statusText = 'No signal';
|
||||
}
|
||||
|
||||
let monitor = container.querySelector('.sstv-signal-monitor');
|
||||
if (!monitor) {
|
||||
container.innerHTML = `
|
||||
<div class="sstv-signal-monitor">
|
||||
<div class="sstv-signal-monitor-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
|
||||
<circle cx="12" cy="18" r="2"/>
|
||||
<path d="M12 16V12"/>
|
||||
</svg>
|
||||
Signal Monitor
|
||||
</div>
|
||||
<div class="sstv-signal-level-row">
|
||||
<span class="sstv-signal-level-label">LEVEL</span>
|
||||
<div class="sstv-signal-bar-track">
|
||||
<div class="sstv-signal-bar-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="sstv-signal-level-value">0</span>
|
||||
</div>
|
||||
<div class="sstv-signal-status-text">No signal</div>
|
||||
<div class="sstv-signal-vis-state">VIS: idle</div>
|
||||
</div>`;
|
||||
monitor = container.querySelector('.sstv-signal-monitor');
|
||||
}
|
||||
|
||||
const fill = monitor.querySelector('.sstv-signal-bar-fill');
|
||||
fill.style.width = level + '%';
|
||||
fill.style.background = barColor;
|
||||
monitor.querySelector('.sstv-signal-status-text').textContent = statusText;
|
||||
monitor.querySelector('.sstv-signal-level-value').textContent = level;
|
||||
|
||||
const visStateEl = monitor.querySelector('.sstv-signal-vis-state');
|
||||
if (visStateEl && data.vis_state) {
|
||||
const stateLabels = {
|
||||
'idle': 'Idle',
|
||||
'leader_1': 'Leader',
|
||||
'break': 'Break',
|
||||
'leader_2': 'Leader 2',
|
||||
'start_bit': 'Start bit',
|
||||
'data_bits': 'Data bits',
|
||||
'parity': 'Parity',
|
||||
'stop_bit': 'Stop bit',
|
||||
};
|
||||
const label = stateLabels[data.vis_state] || data.vis_state;
|
||||
visStateEl.textContent = 'VIS: ' + label;
|
||||
visStateEl.className = 'sstv-signal-vis-state' +
|
||||
(data.vis_state !== 'idle' ? ' active' : '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,18 +946,33 @@ const SSTV = (function() {
|
||||
const liveContent = document.getElementById('sstvLiveContent');
|
||||
if (!liveContent) return;
|
||||
|
||||
liveContent.innerHTML = `
|
||||
<div class="sstv-canvas-container">
|
||||
<canvas id="sstvCanvas" width="320" height="256"></canvas>
|
||||
</div>
|
||||
<div class="sstv-decode-info">
|
||||
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
|
||||
<div class="sstv-progress-bar">
|
||||
<div class="progress" style="width: ${data.progress || 0}%"></div>
|
||||
let container = liveContent.querySelector('.sstv-decode-container');
|
||||
if (!container) {
|
||||
liveContent.innerHTML = `
|
||||
<div class="sstv-decode-container">
|
||||
<div class="sstv-canvas-container">
|
||||
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
|
||||
</div>
|
||||
<div class="sstv-decode-info">
|
||||
<div class="sstv-mode-label"></div>
|
||||
<div class="sstv-progress-bar">
|
||||
<div class="progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="sstv-status-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
container = liveContent.querySelector('.sstv-decode-container');
|
||||
}
|
||||
|
||||
container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...';
|
||||
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
|
||||
container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...';
|
||||
|
||||
if (data.partial_image) {
|
||||
const img = container.querySelector('#sstvDecodeImg');
|
||||
if (img) img.src = data.partial_image;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -739,12 +1026,22 @@ const SSTV = (function() {
|
||||
}
|
||||
|
||||
gallery.innerHTML = images.map(img => `
|
||||
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
|
||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
||||
<div class="sstv-image-card">
|
||||
<div class="sstv-image-card-inner" onclick="SSTV.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
|
||||
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
|
||||
</div>
|
||||
<div class="sstv-image-info">
|
||||
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
|
||||
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||
</div>
|
||||
<div class="sstv-image-actions">
|
||||
<button onclick="event.stopPropagation(); SSTV.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); SSTV.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -876,19 +1173,45 @@ const SSTV = (function() {
|
||||
/**
|
||||
* Show full-size image in modal
|
||||
*/
|
||||
function showImage(url) {
|
||||
let currentModalUrl = null;
|
||||
let currentModalFilename = null;
|
||||
|
||||
function showImage(url, filename) {
|
||||
currentModalUrl = url;
|
||||
currentModalFilename = filename || null;
|
||||
|
||||
let modal = document.getElementById('sstvImageModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'sstvImageModal';
|
||||
modal.className = 'sstv-image-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="sstv-modal-toolbar">
|
||||
<button class="sstv-modal-btn" id="sstvModalDownload" title="Download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download
|
||||
</button>
|
||||
<button class="sstv-modal-btn delete" id="sstvModalDelete" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button class="sstv-modal-close" onclick="SSTV.closeImage()">×</button>
|
||||
<img src="" alt="SSTV Image">
|
||||
`;
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeImage();
|
||||
});
|
||||
modal.querySelector('#sstvModalDownload').addEventListener('click', () => {
|
||||
if (currentModalUrl && currentModalFilename) {
|
||||
downloadImage(currentModalUrl, currentModalFilename);
|
||||
}
|
||||
});
|
||||
modal.querySelector('#sstvModalDelete').addEventListener('click', () => {
|
||||
if (currentModalFilename) {
|
||||
deleteImage(currentModalFilename);
|
||||
}
|
||||
});
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
@@ -927,6 +1250,55 @@ const SSTV = (function() {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single image
|
||||
*/
|
||||
async function deleteImage(filename) {
|
||||
if (!confirm('Delete this image?')) return;
|
||||
try {
|
||||
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = images.filter(img => img.filename !== filename);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
closeImage();
|
||||
showNotification('SSTV', 'Image deleted');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete image:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all images
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (!confirm('Delete all decoded images?')) return;
|
||||
try {
|
||||
const response = await fetch('/sstv/images', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
images = [];
|
||||
updateImageCount(0);
|
||||
renderGallery();
|
||||
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image
|
||||
*/
|
||||
function downloadImage(url, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url + '/download';
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status message
|
||||
*/
|
||||
@@ -947,6 +1319,9 @@ const SSTV = (function() {
|
||||
loadIssSchedule,
|
||||
showImage,
|
||||
closeImage,
|
||||
deleteImage,
|
||||
deleteAllImages,
|
||||
downloadImage,
|
||||
useGPS,
|
||||
updateTLE,
|
||||
stopIssTracking,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Intercept - WebSDR Mode
|
||||
* HF/Shortwave KiwiSDR Network Integration with In-App Audio
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let websdrMap = null;
|
||||
let websdrMarkers = [];
|
||||
let websdrReceivers = [];
|
||||
let websdrInitialized = false;
|
||||
let websdrSpyStationsLoaded = false;
|
||||
|
||||
// KiwiSDR audio state
|
||||
let kiwiWebSocket = null;
|
||||
let kiwiAudioContext = null;
|
||||
let kiwiScriptProcessor = null;
|
||||
let kiwiGainNode = null;
|
||||
let kiwiAudioBuffer = [];
|
||||
let kiwiConnected = false;
|
||||
let kiwiCurrentFreq = 0;
|
||||
let kiwiCurrentMode = 'am';
|
||||
let kiwiSmeter = 0;
|
||||
let kiwiSmeterInterval = null;
|
||||
let kiwiReceiverName = '';
|
||||
|
||||
const KIWI_SAMPLE_RATE = 12000;
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initWebSDR() {
|
||||
if (websdrInitialized) {
|
||||
if (websdrMap) {
|
||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mapEl = document.getElementById('websdrMap');
|
||||
if (!mapEl || typeof L === 'undefined') return;
|
||||
|
||||
// Calculate minimum zoom so tiles fill the container vertically
|
||||
const mapHeight = mapEl.clientHeight || 500;
|
||||
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
||||
|
||||
websdrMap = L.map('websdrMap', {
|
||||
center: [20, 0],
|
||||
zoom: Math.max(minZoom, 2),
|
||||
minZoom: Math.max(minZoom, 2),
|
||||
zoomControl: true,
|
||||
maxBounds: [[-85, -360], [85, 360]],
|
||||
maxBoundsViscosity: 1.0,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}).addTo(websdrMap);
|
||||
|
||||
// Match background to tile ocean color so any remaining edge is seamless
|
||||
mapEl.style.background = '#1a1d29';
|
||||
|
||||
websdrInitialized = true;
|
||||
|
||||
if (!websdrSpyStationsLoaded) {
|
||||
loadSpyStationPresets();
|
||||
}
|
||||
|
||||
[100, 300, 600, 1000].forEach(delay => {
|
||||
setTimeout(() => {
|
||||
if (websdrMap) websdrMap.invalidateSize();
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
// ============== RECEIVER SEARCH ==============
|
||||
|
||||
function searchReceivers(refresh) {
|
||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0);
|
||||
|
||||
let url = '/websdr/receivers?available=true';
|
||||
if (freqKhz > 0) url += `&freq_khz=${freqKhz}`;
|
||||
if (refresh) url += '&refresh=true';
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
websdrReceivers = data.receivers || [];
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnMap(websdrReceivers);
|
||||
|
||||
const countEl = document.getElementById('websdrReceiverCount');
|
||||
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WEBSDR] Search error:', err));
|
||||
}
|
||||
|
||||
// ============== MAP ==============
|
||||
|
||||
function plotReceiversOnMap(receivers) {
|
||||
if (!websdrMap) return;
|
||||
|
||||
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
||||
websdrMarkers = [];
|
||||
|
||||
receivers.forEach((rx, idx) => {
|
||||
if (rx.lat == null || rx.lon == null) return;
|
||||
|
||||
const marker = L.circleMarker([rx.lat, rx.lon], {
|
||||
radius: 6,
|
||||
fillColor: rx.available ? '#00d4ff' : '#666',
|
||||
color: rx.available ? '#00d4ff' : '#666',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6,
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-size: 12px; min-width: 200px;">
|
||||
<strong>${escapeHtmlWebsdr(rx.name)}</strong><br>
|
||||
${rx.location ? `<span style="color: #aaa;">${escapeHtmlWebsdr(rx.location)}</span><br>` : ''}
|
||||
<span style="color: #888;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</span><br>
|
||||
<span style="color: #888;">Users: ${rx.users}/${rx.users_max}</span><br>
|
||||
<button onclick="selectReceiver(${idx})" style="margin-top: 6px; padding: 4px 12px; background: #00d4ff; color: #000; border: none; border-radius: 3px; cursor: pointer; font-weight: bold;">Listen</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(websdrMap);
|
||||
websdrMarkers.push(marker);
|
||||
});
|
||||
|
||||
if (websdrMarkers.length > 0) {
|
||||
const group = L.featureGroup(websdrMarkers);
|
||||
websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
|
||||
// ============== RECEIVER LIST ==============
|
||||
|
||||
function renderReceiverList(receivers) {
|
||||
const container = document.getElementById('websdrReceiverList');
|
||||
if (!container) return;
|
||||
|
||||
if (receivers.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">No receivers found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
|
||||
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
|
||||
onclick="selectReceiver(${idx})">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
||||
</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
||||
${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')}
|
||||
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== SELECT RECEIVER ==============
|
||||
|
||||
function selectReceiver(index) {
|
||||
const rx = websdrReceivers[index];
|
||||
if (!rx) return;
|
||||
|
||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
||||
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
||||
|
||||
kiwiReceiverName = rx.name;
|
||||
|
||||
// Connect via backend proxy
|
||||
connectToReceiver(rx.url, freqKhz, mode);
|
||||
|
||||
// Highlight on map
|
||||
if (websdrMap && rx.lat != null && rx.lon != null) {
|
||||
websdrMap.setView([rx.lat, rx.lon], 6);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== KIWISDR AUDIO CONNECTION ==============
|
||||
|
||||
function connectToReceiver(receiverUrl, freqKhz, mode) {
|
||||
// Disconnect if already connected
|
||||
if (kiwiWebSocket) {
|
||||
disconnectFromReceiver();
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`;
|
||||
|
||||
kiwiWebSocket = new WebSocket(wsUrl);
|
||||
kiwiWebSocket.binaryType = 'arraybuffer';
|
||||
|
||||
kiwiWebSocket.onopen = () => {
|
||||
kiwiWebSocket.send(JSON.stringify({
|
||||
cmd: 'connect',
|
||||
url: receiverUrl,
|
||||
freq_khz: freqKhz,
|
||||
mode: mode,
|
||||
}));
|
||||
updateKiwiUI('connecting');
|
||||
};
|
||||
|
||||
kiwiWebSocket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleKiwiStatus(msg);
|
||||
} else {
|
||||
handleKiwiAudio(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
kiwiWebSocket.onclose = () => {
|
||||
kiwiConnected = false;
|
||||
updateKiwiUI('disconnected');
|
||||
};
|
||||
|
||||
kiwiWebSocket.onerror = () => {
|
||||
updateKiwiUI('disconnected');
|
||||
};
|
||||
}
|
||||
|
||||
function handleKiwiStatus(msg) {
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
kiwiConnected = true;
|
||||
kiwiCurrentFreq = msg.freq_khz;
|
||||
kiwiCurrentMode = msg.mode;
|
||||
initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE);
|
||||
updateKiwiUI('connected');
|
||||
break;
|
||||
case 'tuned':
|
||||
kiwiCurrentFreq = msg.freq_khz;
|
||||
kiwiCurrentMode = msg.mode;
|
||||
updateKiwiUI('connected');
|
||||
break;
|
||||
case 'error':
|
||||
console.error('[KIWI] Error:', msg.message);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('WebSDR', msg.message);
|
||||
}
|
||||
updateKiwiUI('error');
|
||||
break;
|
||||
case 'disconnected':
|
||||
kiwiConnected = false;
|
||||
cleanupKiwiAudio();
|
||||
updateKiwiUI('disconnected');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKiwiAudio(arrayBuffer) {
|
||||
if (arrayBuffer.byteLength < 4) return;
|
||||
|
||||
// First 2 bytes: S-meter (big-endian int16)
|
||||
const view = new DataView(arrayBuffer);
|
||||
kiwiSmeter = view.getInt16(0, false);
|
||||
|
||||
// Remaining bytes: PCM 16-bit signed LE
|
||||
const pcmData = new Int16Array(arrayBuffer, 2);
|
||||
|
||||
// Convert to float32 [-1, 1] for Web Audio API
|
||||
const float32 = new Float32Array(pcmData.length);
|
||||
for (let i = 0; i < pcmData.length; i++) {
|
||||
float32[i] = pcmData[i] / 32768.0;
|
||||
}
|
||||
|
||||
// Add to playback buffer (limit buffer size to ~2s)
|
||||
kiwiAudioBuffer.push(float32);
|
||||
const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512);
|
||||
while (kiwiAudioBuffer.length > maxChunks) {
|
||||
kiwiAudioBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function initKiwiAudioContext(sampleRate) {
|
||||
cleanupKiwiAudio();
|
||||
|
||||
kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: sampleRate,
|
||||
});
|
||||
|
||||
// Resume if suspended (autoplay policy)
|
||||
if (kiwiAudioContext.state === 'suspended') {
|
||||
kiwiAudioContext.resume();
|
||||
}
|
||||
|
||||
// ScriptProcessorNode: pulls audio from buffer
|
||||
kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1);
|
||||
kiwiScriptProcessor.onaudioprocess = (e) => {
|
||||
const output = e.outputBuffer.getChannelData(0);
|
||||
let offset = 0;
|
||||
|
||||
while (offset < output.length && kiwiAudioBuffer.length > 0) {
|
||||
const chunk = kiwiAudioBuffer[0];
|
||||
const needed = output.length - offset;
|
||||
const available = chunk.length;
|
||||
|
||||
if (available <= needed) {
|
||||
output.set(chunk, offset);
|
||||
offset += available;
|
||||
kiwiAudioBuffer.shift();
|
||||
} else {
|
||||
output.set(chunk.subarray(0, needed), offset);
|
||||
kiwiAudioBuffer[0] = chunk.subarray(needed);
|
||||
offset += needed;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining with silence
|
||||
while (offset < output.length) {
|
||||
output[offset++] = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Volume control
|
||||
kiwiGainNode = kiwiAudioContext.createGain();
|
||||
const savedVol = localStorage.getItem('kiwiVolume');
|
||||
kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8;
|
||||
const volValue = Math.round(kiwiGainNode.gain.value * 100);
|
||||
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = volValue;
|
||||
});
|
||||
|
||||
kiwiScriptProcessor.connect(kiwiGainNode);
|
||||
kiwiGainNode.connect(kiwiAudioContext.destination);
|
||||
|
||||
// S-meter display updates
|
||||
if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval);
|
||||
kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200);
|
||||
}
|
||||
|
||||
function disconnectFromReceiver() {
|
||||
if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) {
|
||||
kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' }));
|
||||
}
|
||||
cleanupKiwiAudio();
|
||||
if (kiwiWebSocket) {
|
||||
kiwiWebSocket.close();
|
||||
kiwiWebSocket = null;
|
||||
}
|
||||
kiwiConnected = false;
|
||||
kiwiReceiverName = '';
|
||||
updateKiwiUI('disconnected');
|
||||
}
|
||||
|
||||
function cleanupKiwiAudio() {
|
||||
if (kiwiSmeterInterval) {
|
||||
clearInterval(kiwiSmeterInterval);
|
||||
kiwiSmeterInterval = null;
|
||||
}
|
||||
if (kiwiScriptProcessor) {
|
||||
kiwiScriptProcessor.disconnect();
|
||||
kiwiScriptProcessor = null;
|
||||
}
|
||||
if (kiwiGainNode) {
|
||||
kiwiGainNode.disconnect();
|
||||
kiwiGainNode = null;
|
||||
}
|
||||
if (kiwiAudioContext) {
|
||||
kiwiAudioContext.close().catch(() => {});
|
||||
kiwiAudioContext = null;
|
||||
}
|
||||
kiwiAudioBuffer = [];
|
||||
kiwiSmeter = 0;
|
||||
}
|
||||
|
||||
function tuneKiwi(freqKhz, mode) {
|
||||
if (!kiwiWebSocket || !kiwiConnected) return;
|
||||
kiwiWebSocket.send(JSON.stringify({
|
||||
cmd: 'tune',
|
||||
freq_khz: freqKhz,
|
||||
mode: mode || kiwiCurrentMode,
|
||||
}));
|
||||
}
|
||||
|
||||
function tuneFromBar() {
|
||||
const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0);
|
||||
const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode;
|
||||
if (freq > 0) {
|
||||
tuneKiwi(freq, mode);
|
||||
// Also update sidebar frequency
|
||||
const freqInput = document.getElementById('websdrFrequency');
|
||||
if (freqInput) freqInput.value = freq;
|
||||
}
|
||||
}
|
||||
|
||||
function setKiwiVolume(value) {
|
||||
if (kiwiGainNode) {
|
||||
kiwiGainNode.gain.value = value / 100;
|
||||
localStorage.setItem('kiwiVolume', value);
|
||||
}
|
||||
// Sync both volume sliders
|
||||
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.value !== String(value)) el.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
// ============== S-METER ==============
|
||||
|
||||
function updateSmeterDisplay() {
|
||||
// KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9)
|
||||
const dbm = kiwiSmeter / 10;
|
||||
let sUnit;
|
||||
if (dbm >= -73) {
|
||||
const over = Math.round((dbm + 73));
|
||||
sUnit = over > 0 ? `S9+${over}` : 'S9';
|
||||
} else {
|
||||
sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`;
|
||||
}
|
||||
|
||||
const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27));
|
||||
|
||||
// Update both sidebar and bar S-meter displays
|
||||
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = pct + '%';
|
||||
});
|
||||
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = sUnit;
|
||||
});
|
||||
}
|
||||
|
||||
// ============== UI UPDATES ==============
|
||||
|
||||
function updateKiwiUI(state) {
|
||||
const statusEl = document.getElementById('kiwiStatus');
|
||||
const controlsBar = document.getElementById('kiwiAudioControls');
|
||||
const disconnectBtn = document.getElementById('kiwiDisconnectBtn');
|
||||
const receiverNameEl = document.getElementById('kiwiReceiverName');
|
||||
const freqDisplay = document.getElementById('kiwiFreqDisplay');
|
||||
const barReceiverName = document.getElementById('kiwiBarReceiverName');
|
||||
const barFreq = document.getElementById('kiwiBarFrequency');
|
||||
const barMode = document.getElementById('kiwiBarMode');
|
||||
|
||||
if (state === 'connected') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'CONNECTED';
|
||||
statusEl.style.color = 'var(--accent-green)';
|
||||
}
|
||||
if (controlsBar) controlsBar.style.display = 'block';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'block';
|
||||
if (receiverNameEl) {
|
||||
receiverNameEl.textContent = kiwiReceiverName;
|
||||
receiverNameEl.style.display = 'block';
|
||||
}
|
||||
if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz';
|
||||
if (barReceiverName) barReceiverName.textContent = kiwiReceiverName;
|
||||
if (barFreq) barFreq.value = kiwiCurrentFreq;
|
||||
if (barMode) barMode.value = kiwiCurrentMode;
|
||||
} else if (state === 'connecting') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'CONNECTING...';
|
||||
statusEl.style.color = 'var(--accent-orange)';
|
||||
}
|
||||
} else if (state === 'error') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'ERROR';
|
||||
statusEl.style.color = 'var(--accent-red)';
|
||||
}
|
||||
} else {
|
||||
// disconnected
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'DISCONNECTED';
|
||||
statusEl.style.color = 'var(--text-muted)';
|
||||
}
|
||||
if (controlsBar) controlsBar.style.display = 'none';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'none';
|
||||
if (receiverNameEl) receiverNameEl.style.display = 'none';
|
||||
if (freqDisplay) freqDisplay.textContent = '--- kHz';
|
||||
// Reset both S-meter displays (sidebar + bar)
|
||||
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = '0%';
|
||||
});
|
||||
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = 'S0';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SPY STATION PRESETS ==============
|
||||
|
||||
function loadSpyStationPresets() {
|
||||
fetch('/spy-stations/stations')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
websdrSpyStationsLoaded = true;
|
||||
const container = document.getElementById('websdrSpyPresets');
|
||||
if (!container) return;
|
||||
|
||||
const stations = data.stations || data || [];
|
||||
if (!Array.isArray(stations) || stations.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px;">No stations available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = stations.slice(0, 30).map(s => {
|
||||
const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0];
|
||||
const freqKhz = primaryFreq?.freq_khz || 0;
|
||||
return `
|
||||
<div style="padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
|
||||
onclick="tuneToSpyStation('${escapeHtmlWebsdr(s.id)}', ${freqKhz})"
|
||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'">
|
||||
<div>
|
||||
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtmlWebsdr(s.name)}</span>
|
||||
<span style="color: var(--text-muted); font-size: 9px; margin-left: 4px;">${escapeHtmlWebsdr(s.nickname || '')}</span>
|
||||
</div>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono); font-size: 10px;">${freqKhz} kHz</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[WEBSDR] Failed to load spy station presets:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function tuneToSpyStation(stationId, freqKhz) {
|
||||
const freqInput = document.getElementById('websdrFrequency');
|
||||
if (freqInput) freqInput.value = freqKhz;
|
||||
|
||||
// If already connected, just retune
|
||||
if (kiwiConnected) {
|
||||
const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode;
|
||||
tuneKiwi(freqKhz, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, search for receivers at this frequency
|
||||
fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
websdrReceivers = data.receivers || [];
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnMap(websdrReceivers);
|
||||
|
||||
const countEl = document.getElementById('websdrReceiverCount');
|
||||
if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`;
|
||||
|
||||
if (typeof showNotification === 'function' && data.station) {
|
||||
showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WEBSDR] Spy station receivers error:', err));
|
||||
}
|
||||
|
||||
// ============== UTILITIES ==============
|
||||
|
||||
function escapeHtmlWebsdr(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.initWebSDR = initWebSDR;
|
||||
window.searchReceivers = searchReceivers;
|
||||
window.selectReceiver = selectReceiver;
|
||||
window.tuneToSpyStation = tuneToSpyStation;
|
||||
window.loadSpyStationPresets = loadSpyStationPresets;
|
||||
window.connectToReceiver = connectToReceiver;
|
||||
window.disconnectFromReceiver = disconnectFromReceiver;
|
||||
window.tuneKiwi = tuneKiwi;
|
||||
window.tuneFromBar = tuneFromBar;
|
||||
window.setKiwiVolume = setKiwiVolume;
|
||||
+313
-39
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
|
||||
maxProbes: 1000,
|
||||
};
|
||||
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the API base URL, routing through agent proxy if agent is selected.
|
||||
@@ -59,15 +59,49 @@ const WiFiMode = (function() {
|
||||
/**
|
||||
* Check for agent mode conflicts before starting WiFi scan.
|
||||
*/
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getChannelPresetList(preset) {
|
||||
switch (preset) {
|
||||
case '2.4-common':
|
||||
return '1,6,11';
|
||||
case '2.4-all':
|
||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||
case '5-low':
|
||||
return '36,40,44,48';
|
||||
case '5-mid':
|
||||
return '52,56,60,64';
|
||||
case '5-high':
|
||||
return '149,153,157,161,165';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannelConfig() {
|
||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||
|
||||
const listValue = listInput.trim();
|
||||
const presetValue = getChannelPresetList(preset);
|
||||
|
||||
const channels = listValue || presetValue || '';
|
||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||
|
||||
return {
|
||||
channels: channels || null,
|
||||
channel: Number.isFinite(channel) ? channel : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// State
|
||||
@@ -77,6 +111,7 @@ const WiFiMode = (function() {
|
||||
let scanMode = 'quick'; // 'quick' or 'deep'
|
||||
let eventSource = null;
|
||||
let pollTimer = null;
|
||||
let agentPollTimer = null;
|
||||
|
||||
// Data stores
|
||||
let networks = new Map(); // bssid -> network
|
||||
@@ -460,10 +495,10 @@ const WiFiMode = (function() {
|
||||
setScanning(true, 'deep');
|
||||
|
||||
try {
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channel = document.getElementById('wifiChannel')?.value || null;
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channelConfig = buildChannelConfig();
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
|
||||
let response;
|
||||
if (isAgentMode) {
|
||||
@@ -472,23 +507,25 @@ const WiFiMode = (function() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channel ? parseInt(channel) : null,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channel ? parseInt(channel) : null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -505,8 +542,13 @@ const WiFiMode = (function() {
|
||||
console.log('[WiFiMode] Agent deep scan started:', scanResult);
|
||||
}
|
||||
|
||||
// Start SSE stream for real-time updates
|
||||
// Start SSE stream for real-time updates (works with push-enabled agents)
|
||||
startEventStream();
|
||||
|
||||
// Also start polling for agent data (works without push enabled)
|
||||
if (isAgentMode) {
|
||||
startAgentDeepScanPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WiFiMode] Deep scan error:', error);
|
||||
showError(error.message);
|
||||
@@ -523,6 +565,9 @@ const WiFiMode = (function() {
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
// Stop agent polling
|
||||
stopAgentDeepScanPolling();
|
||||
|
||||
// Close event stream
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
@@ -584,9 +629,18 @@ const WiFiMode = (function() {
|
||||
const status = isAgentMode && data.result ? data.result : data;
|
||||
|
||||
if (status.is_scanning || status.running) {
|
||||
setScanning(true, status.scan_mode);
|
||||
if (status.scan_mode === 'deep') {
|
||||
// Agent returns scan_type in params, local returns scan_mode
|
||||
// Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick'
|
||||
let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep';
|
||||
if (detectedMode === 'deepscan') detectedMode = 'deep';
|
||||
|
||||
setScanning(true, detectedMode);
|
||||
if (detectedMode === 'deep') {
|
||||
startEventStream();
|
||||
// Also start polling for agent mode (works without push enabled)
|
||||
if (isAgentMode) {
|
||||
startAgentDeepScanPolling();
|
||||
}
|
||||
} else {
|
||||
startQuickScanPolling();
|
||||
}
|
||||
@@ -655,6 +709,76 @@ const WiFiMode = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Agent Deep Scan Polling (fallback when push is not enabled)
|
||||
// ==========================================================================
|
||||
|
||||
function startAgentDeepScanPolling() {
|
||||
if (agentPollTimer) return;
|
||||
|
||||
console.log('[WiFiMode] Starting agent deep scan polling...');
|
||||
|
||||
agentPollTimer = setInterval(async () => {
|
||||
if (!isScanning || scanMode !== 'deep') {
|
||||
clearInterval(agentPollTimer);
|
||||
agentPollTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
if (!isAgentMode) {
|
||||
clearInterval(agentPollTimer);
|
||||
agentPollTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status !== 'success' || !result.data) return;
|
||||
|
||||
const data = result.data.data || result.data;
|
||||
const agentName = result.agent_name || 'Remote';
|
||||
|
||||
// Process networks
|
||||
if (data.networks && Array.isArray(data.networks)) {
|
||||
data.networks.forEach(net => {
|
||||
net._agent = agentName;
|
||||
handleStreamEvent({
|
||||
type: 'network_update',
|
||||
network: net
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Process clients
|
||||
if (data.clients && Array.isArray(data.clients)) {
|
||||
data.clients.forEach(client => {
|
||||
client._agent = agentName;
|
||||
handleStreamEvent({
|
||||
type: 'client_update',
|
||||
client: client
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`);
|
||||
|
||||
} catch (error) {
|
||||
console.debug('[WiFiMode] Agent poll error:', error);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
}
|
||||
|
||||
function stopAgentDeepScanPolling() {
|
||||
if (agentPollTimer) {
|
||||
clearInterval(agentPollTimer);
|
||||
agentPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SSE Event Stream
|
||||
// ==========================================================================
|
||||
@@ -791,6 +915,7 @@ const WiFiMode = (function() {
|
||||
updateNetworkRow(network);
|
||||
updateStats();
|
||||
updateProximityRadar();
|
||||
updateChannelChart();
|
||||
|
||||
if (onNetworkUpdate) onNetworkUpdate(network);
|
||||
}
|
||||
@@ -799,6 +924,9 @@ const WiFiMode = (function() {
|
||||
clients.set(client.mac, client);
|
||||
updateStats();
|
||||
|
||||
// Update client display if this client belongs to the selected network
|
||||
updateClientInList(client);
|
||||
|
||||
if (onClientUpdate) onClientUpdate(client);
|
||||
}
|
||||
|
||||
@@ -1047,6 +1175,9 @@ const WiFiMode = (function() {
|
||||
|
||||
// Show the drawer
|
||||
elements.detailDrawer.classList.add('open');
|
||||
|
||||
// Fetch and display clients for this network
|
||||
fetchClientsForNetwork(network.bssid);
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
@@ -1059,6 +1190,130 @@ const WiFiMode = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Client Display
|
||||
// ==========================================================================
|
||||
|
||||
async function fetchClientsForNetwork(bssid) {
|
||||
if (!elements.detailClientList) return;
|
||||
|
||||
try {
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
let response;
|
||||
|
||||
if (isAgentMode) {
|
||||
// Route through agent proxy
|
||||
response = await fetch(`/controller/agents/${currentAgent}/wifi/v2/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Hide client list on error
|
||||
elements.detailClientList.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Handle agent response format (may be nested in 'result')
|
||||
const result = isAgentMode && data.result ? data.result : data;
|
||||
const clientList = result.clients || [];
|
||||
|
||||
if (clientList.length > 0) {
|
||||
renderClientList(clientList, bssid);
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('[WiFiMode] Error fetching clients:', error);
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderClientList(clientList, bssid) {
|
||||
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
||||
const countBadge = document.getElementById('wifiClientCountBadge');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
// Update count badge
|
||||
if (countBadge) {
|
||||
countBadge.textContent = clientList.length;
|
||||
}
|
||||
|
||||
// Render client cards
|
||||
container.innerHTML = clientList.map(client => {
|
||||
const rssi = client.rssi_current;
|
||||
const signalClass = rssi >= -50 ? 'signal-strong' :
|
||||
rssi >= -70 ? 'signal-medium' :
|
||||
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
||||
|
||||
// Format last seen time
|
||||
const lastSeen = client.last_seen ? formatTime(client.last_seen) : '--';
|
||||
|
||||
// Build probed SSIDs badges
|
||||
let probesHtml = '';
|
||||
if (client.probed_ssids && client.probed_ssids.length > 0) {
|
||||
const probes = client.probed_ssids.slice(0, 5); // Show max 5
|
||||
probesHtml = `
|
||||
<div class="wifi-client-probes">
|
||||
${probes.map(ssid => `<span class="wifi-client-probe-badge">${escapeHtml(ssid)}</span>`).join('')}
|
||||
${client.probed_ssids.length > 5 ? `<span class="wifi-client-probe-badge">+${client.probed_ssids.length - 5}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="wifi-client-card" data-mac="${escapeHtml(client.mac)}">
|
||||
<div class="wifi-client-identity">
|
||||
<span class="wifi-client-mac">${escapeHtml(client.mac)}</span>
|
||||
<span class="wifi-client-vendor">${escapeHtml(client.vendor || 'Unknown vendor')}</span>
|
||||
${probesHtml}
|
||||
</div>
|
||||
<div class="wifi-client-signal">
|
||||
<span class="wifi-client-rssi ${signalClass}">${rssi !== null && rssi !== undefined ? rssi + ' dBm' : '--'}</span>
|
||||
<span class="wifi-client-lastseen">${lastSeen}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateClientInList(client) {
|
||||
// Check if this client belongs to the currently selected network
|
||||
if (!selectedNetwork || client.associated_bssid !== selectedNetwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
||||
if (!container) return;
|
||||
|
||||
const existingCard = container.querySelector(`[data-mac="${client.mac}"]`);
|
||||
|
||||
if (existingCard) {
|
||||
// Update existing card's RSSI and last seen
|
||||
const rssiEl = existingCard.querySelector('.wifi-client-rssi');
|
||||
const lastSeenEl = existingCard.querySelector('.wifi-client-lastseen');
|
||||
|
||||
if (rssiEl && client.rssi_current !== null && client.rssi_current !== undefined) {
|
||||
const rssi = client.rssi_current;
|
||||
const signalClass = rssi >= -50 ? 'signal-strong' :
|
||||
rssi >= -70 ? 'signal-medium' :
|
||||
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
||||
rssiEl.textContent = rssi + ' dBm';
|
||||
rssiEl.className = 'wifi-client-rssi ' + signalClass;
|
||||
}
|
||||
|
||||
if (lastSeenEl && client.last_seen) {
|
||||
lastSeenEl.textContent = formatTime(client.last_seen);
|
||||
}
|
||||
} else {
|
||||
// New client for this network - re-fetch the full list
|
||||
fetchClientsForNetwork(selectedNetwork);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Statistics
|
||||
// ==========================================================================
|
||||
@@ -1202,9 +1457,15 @@ const WiFiMode = (function() {
|
||||
return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel));
|
||||
}
|
||||
|
||||
function updateChannelChart(band = '2.4') {
|
||||
function updateChannelChart(band) {
|
||||
if (typeof ChannelChart === 'undefined') return;
|
||||
|
||||
// Use the currently active band tab if no band specified
|
||||
if (!band) {
|
||||
const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active');
|
||||
band = activeTab ? activeTab.dataset.band : '2.4';
|
||||
}
|
||||
|
||||
// Recalculate channel stats from networks if needed
|
||||
if (channelStats.length === 0 && networks.size > 0) {
|
||||
channelStats = calculateChannelStats();
|
||||
@@ -1292,9 +1553,19 @@ const WiFiMode = (function() {
|
||||
|
||||
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
|
||||
|
||||
// Stop any running scan
|
||||
// Stop UI polling only - don't stop the actual scan on the agent
|
||||
// The agent should continue running independently
|
||||
if (isScanning) {
|
||||
stopScan();
|
||||
stopAgentDeepScanPolling();
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
setScanning(false);
|
||||
}
|
||||
|
||||
// Clear existing data when switching agents (unless "Show All" is enabled)
|
||||
@@ -1306,6 +1577,9 @@ const WiFiMode = (function() {
|
||||
// Refresh capabilities for new agent
|
||||
checkCapabilities();
|
||||
|
||||
// Check if new agent already has a scan running
|
||||
checkScanStatus();
|
||||
|
||||
lastAgentId = currentAgentId;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*!
|
||||
* chartjs-adapter-date-fns v3.0.0 - Lightweight date adapter for Chart.js
|
||||
* Uses native Date parsing (no external dependencies)
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
const FORMATS = {
|
||||
datetime: 'MMM d, yyyy, h:mm:ss a',
|
||||
millisecond: 'h:mm:ss.SSS a',
|
||||
second: 'h:mm:ss a',
|
||||
minute: 'h:mm a',
|
||||
hour: 'ha',
|
||||
day: 'MMM d',
|
||||
week: 'PP',
|
||||
month: 'MMM yyyy',
|
||||
quarter: "'Q'Q - yyyy",
|
||||
year: 'yyyy'
|
||||
};
|
||||
|
||||
function formatDate(date, fmt) {
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
const s = d.getSeconds();
|
||||
const ms = d.getMilliseconds();
|
||||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
const h12 = h % 12 || 12;
|
||||
|
||||
switch(fmt) {
|
||||
case 'h:mm:ss.SSS a':
|
||||
return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')} ${ampm}`;
|
||||
case 'h:mm:ss a':
|
||||
return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`;
|
||||
case 'h:mm a':
|
||||
return `${h12}:${String(m).padStart(2,'0')} ${ampm}`;
|
||||
case 'ha':
|
||||
return `${h12}${ampm}`;
|
||||
case 'MMM d':
|
||||
return `${months[d.getMonth()]} ${d.getDate()}`;
|
||||
case 'MMM yyyy':
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
case 'yyyy':
|
||||
return `${d.getFullYear()}`;
|
||||
default:
|
||||
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}, ${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`;
|
||||
}
|
||||
}
|
||||
|
||||
const UNITS = ['millisecond','second','minute','hour','day','week','month','quarter','year'];
|
||||
const UNIT_MS = {
|
||||
millisecond: 1,
|
||||
second: 1000,
|
||||
minute: 60000,
|
||||
hour: 3600000,
|
||||
day: 86400000,
|
||||
week: 604800000,
|
||||
month: 2592000000,
|
||||
quarter: 7776000000,
|
||||
year: 31536000000
|
||||
};
|
||||
|
||||
if (typeof Chart !== 'undefined' && Chart._adapters && Chart._adapters._date) {
|
||||
const adapter = Chart._adapters._date;
|
||||
adapter.override({
|
||||
_id: 'date-fns-lite',
|
||||
formats: function() { return FORMATS; },
|
||||
parse: function(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'number') return value;
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? null : d.getTime();
|
||||
},
|
||||
format: function(time, fmt) {
|
||||
return formatDate(time, fmt);
|
||||
},
|
||||
add: function(time, amount, unit) {
|
||||
const d = new Date(time);
|
||||
switch(unit) {
|
||||
case 'millisecond': d.setTime(d.getTime() + amount); break;
|
||||
case 'second': d.setSeconds(d.getSeconds() + amount); break;
|
||||
case 'minute': d.setMinutes(d.getMinutes() + amount); break;
|
||||
case 'hour': d.setHours(d.getHours() + amount); break;
|
||||
case 'day': d.setDate(d.getDate() + amount); break;
|
||||
case 'week': d.setDate(d.getDate() + amount * 7); break;
|
||||
case 'month': d.setMonth(d.getMonth() + amount); break;
|
||||
case 'quarter': d.setMonth(d.getMonth() + amount * 3); break;
|
||||
case 'year': d.setFullYear(d.getFullYear() + amount); break;
|
||||
}
|
||||
return d.getTime();
|
||||
},
|
||||
diff: function(max, min, unit) {
|
||||
return (max - min) / (UNIT_MS[unit] || 1);
|
||||
},
|
||||
startOf: function(time, unit) {
|
||||
const d = new Date(time);
|
||||
switch(unit) {
|
||||
case 'second': d.setMilliseconds(0); break;
|
||||
case 'minute': d.setSeconds(0,0); break;
|
||||
case 'hour': d.setMinutes(0,0,0); break;
|
||||
case 'day': d.setHours(0,0,0,0); break;
|
||||
case 'week': d.setHours(0,0,0,0); d.setDate(d.getDate() - d.getDay()); break;
|
||||
case 'month': d.setHours(0,0,0,0); d.setDate(1); break;
|
||||
case 'quarter': d.setHours(0,0,0,0); d.setMonth(d.getMonth() - d.getMonth() % 3, 1); break;
|
||||
case 'year': d.setHours(0,0,0,0); d.setMonth(0,1); break;
|
||||
}
|
||||
return d.getTime();
|
||||
},
|
||||
endOf: function(time, unit) {
|
||||
const d = new Date(time);
|
||||
switch(unit) {
|
||||
case 'second': d.setMilliseconds(999); break;
|
||||
case 'minute': d.setSeconds(59,999); break;
|
||||
case 'hour': d.setMinutes(59,59,999); break;
|
||||
case 'day': d.setHours(23,59,59,999); break;
|
||||
case 'month': d.setMonth(d.getMonth()+1,0); d.setHours(23,59,59,999); break;
|
||||
case 'year': d.setMonth(11,31); d.setHours(23,59,59,999); break;
|
||||
}
|
||||
return d.getTime();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user