Compare commits
333 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db304631f8 | |||
| eae1820fda | |||
| f70deb32a2 | |||
| 69eea1e895 | |||
| bf4346b4ff | |||
| 7cde6a2068 | |||
| 84b424b02e | |||
| 04b73596ea | |||
| 3916276de8 | |||
| 077d46f319 | |||
| a0fd6d9651 | |||
| 8d505eb848 | |||
| 3f364f47e9 | |||
| b92139f207 | |||
| c7e9a0a493 | |||
| 717dec4e54 | |||
| d3cb20cdae | |||
| 518da075de | |||
| fb31157fe9 | |||
| a5f574062d | |||
| afccb6fe0a | |||
| f916b9fa19 | |||
| d775ba5b3e | |||
| 3372daca84 | |||
| b72ddd7c19 | |||
| f980e2e76d | |||
| ada6d5f1f1 | |||
| 7c6416ac38 | |||
| e833488425 | |||
| 0b8863aaa9 | |||
| 8d30c40fe2 | |||
| d2f2c37531 | |||
| b23a1636b0 | |||
| a73a74d1fc | |||
| d297f87115 | |||
| 88537c1119 | |||
| 141b34391d | |||
| 8b4b440b22 | |||
| 0cccf3c9dd | |||
| e532f67c85 | |||
| 7a2b90055a | |||
| ab2d7bfe50 | |||
| 1e2810b85c | |||
| 164887f8a4 | |||
| b4d3e65a3d | |||
| 3b238c3c8f | |||
| 93111b93c5 | |||
| 6a63c13cd8 | |||
| 3518f7fede | |||
| 79fc2871c9 | |||
| 2d21ce9303 | |||
| 28e63a1029 | |||
| cbfe46201e | |||
| 1b0d39c5b0 | |||
| 446a8f14cb | |||
| 57d448c003 | |||
| eabc73ff49 | |||
| f724421ce7 | |||
| 9134195eb1 | |||
| ee6971284c | |||
| 098fab6aca | |||
| bc2b2bf23b | |||
| eb5bf55aad | |||
| 17a0dddf61 | |||
| f6bd38e3dc | |||
| 12db4f5178 | |||
| f01502ff32 | |||
| 54a47b03c2 | |||
| 537171d788 | |||
| f665203543 | |||
| dfd4b0e89e | |||
| 45c10a8593 | |||
| d929c30882 | |||
| 0ca3066cfc | |||
| 1d30ea2708 | |||
| 6ae21e9e24 | |||
| 5843b3dcc5 | |||
| 1cd367332b | |||
| 9515f5fd7a | |||
| e22f464300 | |||
| 3d0c505178 | |||
| a1f8377dd4 | |||
| 588556c2a6 | |||
| af078aaae0 | |||
| 9dccbb95e8 | |||
| 226f08f62d | |||
| 85159cbc44 | |||
| 201fce0125 | |||
| 3b8d4f3f74 | |||
| 852d109468 | |||
| c5eb63ae7f | |||
| b0ab361ead | |||
| 7b2e1caa47 | |||
| 7957176e59 | |||
| bd7c83b18c | |||
| 27a0e095a3 | |||
| e19315819d | |||
| 002afe3690 | |||
| 9e31bc65db | |||
| 898410b225 | |||
| fe28a91d5c | |||
| be58c00bc7 | |||
| 91b07fe797 | |||
| bac7f8d55c | |||
| bb660d02f5 | |||
| e3d9349d4b | |||
| 78642bcbb2 | |||
| 48e3bf210a | |||
| e9d5fe35fb | |||
| 66f16d4a2d | |||
| 187347e64b | |||
| 5016327bc2 | |||
| ed460761ff | |||
| c49b1e03f2 | |||
| 28d15d0ed5 | |||
| 54db023520 | |||
| 713c1a3470 | |||
| 5bafb88377 | |||
| 95f3836edd | |||
| 0195553a62 | |||
| 5c7554d6cb | |||
| ec32b9237e | |||
| 3edd40de0d | |||
| 88418b0850 | |||
| 1e59cfd2ea | |||
| 42f2a6ef62 | |||
| 3e3bc0e857 | |||
| 290c5ff896 | |||
| 4c0d44a99d | |||
| ef4adfe003 | |||
| 30dfea57b9 | |||
| a0d7f221c0 | |||
| ee916d0022 | |||
| 156d832d2d | |||
| abe3d42004 | |||
| 3f38742dbe | |||
| 2cb62d5f34 | |||
| 256c30e7cd | |||
| c92f60e0f3 | |||
| 9461cc2121 | |||
| 8a744eb55a | |||
| 73188c2471 | |||
| 6e8de37135 | |||
| bb010664ca | |||
| ffc55efe1c | |||
| 8b42f4ac28 | |||
| 4c71a3bb92 | |||
| d88d5c4921 | |||
| 5c62ae316a | |||
| ed58681800 | |||
| 90d2d42478 | |||
| c88cf831fc | |||
| f6aed7deda | |||
| ce204ce413 | |||
| 1ef3e367eb | |||
| 7cd988b777 | |||
| aac88cdd29 | |||
| 664ae5b5ce | |||
| d268e581bd | |||
| ecc8dad2e2 | |||
| df025f0409 | |||
| 5e4412879d | |||
| ce232e0512 | |||
| 5d54449b21 | |||
| 04f003c9f0 | |||
| 9b55632c86 | |||
| bd65679572 | |||
| f93877d723 | |||
| 2b8b499e79 | |||
| 69410fd7c2 | |||
| 176014b706 | |||
| 92984a7bae | |||
| a5d433b516 | |||
| e30094e8fc | |||
| f1b416bba5 | |||
| ec0b8dbcf7 | |||
| 5bfa7bf651 | |||
| e204901d18 | |||
| 482d778bca | |||
| c4ad8f6c12 | |||
| aa763b0f81 | |||
| 58a825976d | |||
| e4e9e89451 | |||
| 2f2e56ff2e | |||
| 2b29b5c86f | |||
| af1cb7c17b | |||
| c5aa382527 | |||
| 78f81eeccd | |||
| 096763ad40 | |||
| 6354911c54 | |||
| a8bb56a109 | |||
| 5047fee431 | |||
| b63c7ab0fe | |||
| c0c86ef601 | |||
| 69c765d44a | |||
| 617ba859fb | |||
| 62db171ed6 | |||
| 66b2f59ca0 | |||
| 6dbf2fda01 | |||
| 234f254f4f | |||
| 3210fc0d20 | |||
| ac68e26c70 | |||
| ce0f581938 | |||
| fc48ff7d9f | |||
| af39d40847 | |||
| fb23766ed3 | |||
| bcb3147d1e | |||
| 940a43747b | |||
| 16c74d10db | |||
| a99c3e3894 | |||
| e621647768 | |||
| 5992156356 | |||
| bed0c5fb8d | |||
| 0362a1b4ea | |||
| cf7c94f9d8 | |||
| c044ecfba2 | |||
| 23a79a7ac5 | |||
| 795dd3f235 | |||
| 35d138175e | |||
| 4c1690dd28 | |||
| 407d5c1d25 | |||
| f46681fdbc | |||
| 95e0309c63 | |||
| 819944cccf | |||
| c595450310 | |||
| 4af61c8cb9 | |||
| 9f391527c2 | |||
| cd168da760 | |||
| f4282cb608 | |||
| 073134d6d3 | |||
| 4baefa61ac | |||
| 0d6d81fb69 | |||
| c96a3ade6b | |||
| 81c9dd84b2 | |||
| fe67461f88 | |||
| aae60e2037 | |||
| 97d5ec6b33 | |||
| 459bf2d8cd | |||
| 43f0f1cbfc | |||
| a3fd6881df | |||
| b27a532bce | |||
| 52f85669f8 | |||
| a891160f98 | |||
| 130bc8a51c | |||
| 4224418e6f | |||
| 4018f95723 | |||
| e6c7a3eae4 | |||
| 2e27efdfbf | |||
| 6efa10643e | |||
| 71e5803695 | |||
| 1107f0e534 | |||
| 0b22d0aa1f | |||
| 353cd16021 | |||
| ac6d1b570d | |||
| 319ea2d01d | |||
| 6fc64937fb | |||
| 323f24a470 | |||
| d98bcc15b8 | |||
| fdd91485fc | |||
| d510ba30f6 | |||
| 4bb0c9b9a3 | |||
| b3e67e5ef6 | |||
| dec890104b | |||
| 5d8c435c5a | |||
| 3cf371242a | |||
| aab7b508cc | |||
| 36def8f96a | |||
| 3c0a654f93 | |||
| 77b4bc9ad4 | |||
| 9f39f1cc2f | |||
| f326be77cd | |||
| 7eba7dbaaa | |||
| dc4434db84 | |||
| 0eed4a2649 | |||
| 7b49c95967 | |||
| 30126b1709 | |||
| 66c7db73e2 | |||
| 07af3acb84 | |||
| b2feccdb90 | |||
| db2f46b46e | |||
| ff7c768287 | |||
| 236fbf061c | |||
| 21b0a153e8 | |||
| 35ca3f3a07 | |||
| 87f72db8ad | |||
| 93b763865b | |||
| b15b5ad9ba | |||
| 364600e545 | |||
| 23b2a2a0c0 | |||
| ef6eec3cf8 | |||
| 94f4682f2f | |||
| f407a3cb54 | |||
| c11c1200e2 | |||
| 0acbf87dde | |||
| 153336d757 | |||
| 570710c556 | |||
| de13d5ea74 | |||
| f36e528086 | |||
| 52ce930c31 | |||
| bb694c9926 | |||
| a8c77c8db3 | |||
| 3263638c57 | |||
| c30e5800df | |||
| 161e0d8ea8 | |||
| 93f68aa29d | |||
| c5ce35ff13 | |||
| 7069c8b636 | |||
| 6149427753 | |||
| 536b762f97 | |||
| b423dcedf7 | |||
| 16cd1fef2d | |||
| c94d0a642d | |||
| 135390788d | |||
| 98e4e38809 | |||
| 6d5a12a21f | |||
| fe3b3b536c | |||
| aa8a6baac4 | |||
| b0982249c3 | |||
| cf91c2484f | |||
| b3a8a69244 | |||
| f51b193876 | |||
| 0846d1f360 | |||
| dd56617c4c | |||
| 03ce847196 | |||
| 1a7a33041c | |||
| 6da8b11301 | |||
| 8cd1ecffc4 | |||
| 7967b71405 | |||
| cd0d5971e2 | |||
| b52b4db989 | |||
| ef5cfb4908 | |||
| ee7781ee67 | |||
| 8c5bb32ec6 |
@@ -15,6 +15,7 @@ venv/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
.uv
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -32,6 +33,9 @@ htmlcov/
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local Postgres data
|
||||
pgdata/
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
*.pcap
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: smittix
|
||||
@@ -14,6 +14,14 @@ uv.lock
|
||||
*.log
|
||||
pager_messages.log
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -30,5 +38,19 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Package manager lock files
|
||||
# Package manager lock files & DB files
|
||||
uv.lock
|
||||
*.db
|
||||
*.sqlite3
|
||||
intercept.db
|
||||
|
||||
# Instance folder (contains database with user data)
|
||||
instance/
|
||||
|
||||
# Agent configs with real credentials (keep template only)
|
||||
intercept_agent_*.cfg
|
||||
!intercept_agent.cfg
|
||||
|
||||
# Temporary files
|
||||
/tmp/
|
||||
*.tmp
|
||||
|
||||
@@ -2,6 +2,116 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.11.0] - 2026-01-28
|
||||
|
||||
### Added
|
||||
- **Meshtastic Mesh Network Integration** - LoRa mesh communication support
|
||||
- Connect to Meshtastic devices (Heltec, T-Beam, RAK) via USB/Serial
|
||||
- Real-time message streaming via SSE
|
||||
- Channel configuration with encryption key support
|
||||
- Node information display with signal metrics (RSSI, SNR)
|
||||
- Message history with up to 500 messages
|
||||
- **Ubertooth One BLE Scanner** - Advanced Bluetooth scanning
|
||||
- Passive BLE packet capture across all 40 BLE channels
|
||||
- Raw advertising payload access
|
||||
- Integration with existing Bluetooth scanning modes
|
||||
- Automatic detection of Ubertooth hardware
|
||||
- **Offline Mode** - Run iNTERCEPT without internet connectivity
|
||||
- Bundled Leaflet 1.9.4 (JS, CSS, marker images)
|
||||
- Bundled Chart.js 4.4.1
|
||||
- Bundled Inter and JetBrains Mono fonts (woff2)
|
||||
- Local asset status checking and validation
|
||||
- **Settings Modal** - New configuration interface accessible from navigation
|
||||
- Offline tab: Toggle offline mode, configure asset sources
|
||||
- Display tab: Theme and animation preferences
|
||||
- About tab: Version info and links
|
||||
- **Multiple Map Tile Providers** - Choose from:
|
||||
- OpenStreetMap (default)
|
||||
- CartoDB Dark
|
||||
- CartoDB Positron (light)
|
||||
- ESRI World Imagery
|
||||
- Custom tile server URL
|
||||
|
||||
### Changed
|
||||
- **Dashboard Templates** - Conditional asset loading based on offline settings
|
||||
- **Bluetooth Scanner** - Added Ubertooth backend alongside BlueZ/DBus
|
||||
- **Dependencies** - Added meshtastic SDK to requirements.txt
|
||||
|
||||
### Technical
|
||||
- Added `routes/meshtastic.py` for Meshtastic API endpoints
|
||||
- Added `utils/meshtastic.py` for device management
|
||||
- Added `utils/bluetooth/ubertooth_scanner.py` for Ubertooth support
|
||||
- Added `routes/offline.py` for offline mode API
|
||||
- Added `static/js/core/settings-manager.js` for client-side settings
|
||||
- Added `static/css/settings.css` for settings modal styles
|
||||
- Added `static/css/modes/meshtastic.css` for Meshtastic UI
|
||||
- Added `static/js/modes/meshtastic.js` for Meshtastic frontend
|
||||
- Added `templates/partials/modes/meshtastic.html` for Meshtastic mode
|
||||
- Added `templates/partials/settings-modal.html` for settings UI
|
||||
- Added `static/vendor/` directory structure for bundled assets
|
||||
|
||||
---
|
||||
|
||||
## [2.10.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
|
||||
- Full-screen dashboard with interactive maritime map
|
||||
- Vessel details: name, MMSI, callsign, destination, ETA
|
||||
- Navigation data: speed, course, heading, rate of turn
|
||||
- Ship type classification and dimensions
|
||||
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
|
||||
- **VHF DSC Channel 70 Monitoring** - Digital Selective Calling for maritime distress
|
||||
- Real-time decoding of DSC messages (Distress, Urgency, Safety, Routine)
|
||||
- MMSI country identification via Maritime Identification Digits (MID) lookup
|
||||
- Position extraction and map markers for distress alerts
|
||||
- Prominent visual overlay for DISTRESS and URGENCY alerts
|
||||
- Permanent database storage for critical alerts with acknowledgement workflow
|
||||
- **Spy Stations Database** - Number stations and diplomatic HF networks
|
||||
- Comprehensive database from priyom.org
|
||||
- Station profiles with frequencies, schedules, operators
|
||||
- Filter by type (number/diplomatic), country, and mode
|
||||
- Tune integration with Listening Post
|
||||
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
|
||||
- **SDR Device Conflict Detection** - Prevents collisions between AIS and DSC
|
||||
- **DSC Alert Summary** - Dashboard counts for unacknowledged distress/urgency alerts
|
||||
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
|
||||
|
||||
### Changed
|
||||
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
|
||||
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
|
||||
- **Vessels Dashboard** - Now includes VHF DSC message panel alongside AIS tracking
|
||||
- **Dependencies** - Added scipy and numpy for DSC signal processing
|
||||
|
||||
### Fixed
|
||||
- **DSC Position Decoder** - Corrected octal literal in quadrant check
|
||||
|
||||
---
|
||||
|
||||
## [2.9.5] - 2026-01-14
|
||||
|
||||
### Added
|
||||
- **MAC-Randomization Resistant Detection** - TSCM now identifies devices using randomized MAC addresses
|
||||
- **Clickable Score Cards** - Click on threat scores to see detailed findings
|
||||
- **Device Detail Expansion** - Click-to-expand device details in TSCM results
|
||||
- **Root Privilege Check** - Warning display when running without required privileges
|
||||
- **Real-time Device Streaming** - Devices stream to dashboard during TSCM sweep
|
||||
|
||||
### Changed
|
||||
- **TSCM Correlation Engine** - Improved device correlation with comprehensive reporting
|
||||
- **Device Classification System** - Enhanced threat classification and scoring
|
||||
- **WiFi Scanning** - Improved scanning reliability and device naming
|
||||
|
||||
### Fixed
|
||||
- **RF Scanning** - Fixed scanning issues with improved status feedback
|
||||
- **TSCM Modal Readability** - Improved modal styling and close button visibility
|
||||
- **Linux Device Detection** - Added more fallback methods for device detection
|
||||
- **macOS Device Detection** - Fixed TSCM device detection on macOS
|
||||
- **Bluetooth Event Type** - Fixed device type being overwritten
|
||||
- **rtl_433 Bias-T Flag** - Corrected bias-t flag handling
|
||||
|
||||
---
|
||||
|
||||
## [2.9.0] - 2026-01-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Setup and Running
|
||||
```bash
|
||||
# Initial setup (installs dependencies and configures SDR tools)
|
||||
./setup.sh
|
||||
|
||||
# Run the application (requires sudo for SDR/network access)
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Or activate venv first
|
||||
source venv/bin/activate
|
||||
sudo -E python intercept.py
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_bluetooth.py
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=routes --cov=utils
|
||||
|
||||
# Run a specific test
|
||||
pytest tests/test_bluetooth.py::test_function_name -v
|
||||
```
|
||||
|
||||
### Linting and Formatting
|
||||
```bash
|
||||
# Lint with ruff
|
||||
ruff check .
|
||||
|
||||
# Auto-fix linting issues
|
||||
ruff check --fix .
|
||||
|
||||
# Format with black
|
||||
black .
|
||||
|
||||
# Type checking
|
||||
mypy .
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Entry Points
|
||||
- `intercept.py` - Main entry point script
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
||||
|
||||
### Route Blueprints (routes/)
|
||||
Each signal type has its own Flask blueprint:
|
||||
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
|
||||
- `sensor.py` - 433MHz IoT sensors via rtl_433
|
||||
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
|
||||
- `acars.py` - Aircraft datalink messages via acarsdec
|
||||
- `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
|
||||
- `aprs.py` - Amateur packet radio via direwolf
|
||||
- `rtlamr.py` - Utility meter reading
|
||||
|
||||
### Core Utilities (utils/)
|
||||
|
||||
**SDR Abstraction Layer** (`utils/sdr/`):
|
||||
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
|
||||
- Each type has a `CommandBuilder` for generating CLI commands
|
||||
|
||||
**Bluetooth Module** (`utils/bluetooth/`):
|
||||
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
|
||||
- `aggregator.py` - Merges observations across time
|
||||
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
|
||||
- `heuristics.py` - Behavioral analysis for device classification
|
||||
|
||||
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
|
||||
- `baseline.py` - Snapshot "normal" RF environment
|
||||
- `detector.py` - Compare current scan to baseline, flag anomalies
|
||||
- `device_identity.py` - Track devices despite MAC randomization
|
||||
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
|
||||
|
||||
**WiFi Utilities** (`utils/wifi/`):
|
||||
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||
- `channel_analyzer.py` - Frequency band analysis
|
||||
|
||||
### 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.
|
||||
|
||||
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||
|
||||
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
|
||||
|
||||
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
|
||||
|
||||
### External Tool Integrations
|
||||
|
||||
| Tool | Purpose | Integration |
|
||||
|------|---------|-------------|
|
||||
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
|
||||
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
|
||||
| rtl_433 | 433MHz sensors | JSON output parsing |
|
||||
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
|
||||
| acarsdec | ACARS messages | Output parsing |
|
||||
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||
|
||||
### Configuration
|
||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix
|
||||
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||
|
||||
## Testing Notes
|
||||
|
||||
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
|
||||
@@ -31,17 +31,100 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# GPS support
|
||||
gpsd-clients \
|
||||
# Utilities
|
||||
# APRS
|
||||
direwolf \
|
||||
# WiFi Extra
|
||||
hcxdumptool \
|
||||
hcxtools \
|
||||
# SDR Hardware & SoapySDR
|
||||
soapysdr-tools \
|
||||
soapysdr-module-rtlsdr \
|
||||
soapysdr-module-hackrf \
|
||||
soapysdr-module-lms7 \
|
||||
limesuite \
|
||||
hackrf \
|
||||
# Utilities
|
||||
curl \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dump1090 for ADS-B (package name varies by distribution)
|
||||
RUN apt-get update && \
|
||||
(apt-get install -y --no-install-recommends dump1090-mutability || \
|
||||
apt-get install -y --no-install-recommends dump1090-fa || \
|
||||
apt-get install -y --no-install-recommends dump1090 || \
|
||||
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
# Build dump1090
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||
&& cd dump1090 \
|
||||
&& make \
|
||||
&& cp dump1090 /usr/bin/dump1090-fa \
|
||||
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
||||
&& rm -rf /tmp/dump1090 \
|
||||
# Build AIS-catcher
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
||||
&& cd AIS-catcher \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& cp AIS-catcher /usr/bin/AIS-catcher \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/AIS-catcher \
|
||||
# Build readsb
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
||||
&& cd readsb \
|
||||
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
||||
&& cp readsb /usr/bin/readsb \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/readsb \
|
||||
# Build rx_tools
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/rxseger/rx_tools.git \
|
||||
&& cd rx_tools \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/rx_tools \
|
||||
# Build acarsdec
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||
&& cd acarsdec \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. -Drtl=ON \
|
||||
&& make \
|
||||
&& cp acarsdec /usr/bin/acarsdec \
|
||||
&& rm -rf /tmp/acarsdec \
|
||||
# Cleanup build tools to reduce image size
|
||||
&& apt-get remove -y \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -6,13 +6,20 @@
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Support the developer of this open-source project
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<strong>Signal Intelligence Platform</strong><br>
|
||||
A web-based interface for software-defined radio tools.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
||||
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -22,10 +29,17 @@
|
||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||
- **Listening Post** - Frequency scanner with audio monitoring
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **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
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +52,7 @@
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Docker (Alternative)
|
||||
@@ -46,14 +60,27 @@ sudo python3 intercept.py
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||
|
||||
### ADS-B History (Optional)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
|
||||
```bash
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Then open **/adsb/history** for the reporting dashboard.
|
||||
|
||||
### Open the Interface
|
||||
|
||||
After starting, open **http://localhost:5050** in your browser.
|
||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||
|
||||
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
|
||||
---
|
||||
|
||||
@@ -81,14 +108,16 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
## Discord Server
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
|
||||
</p>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
|
||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||
@@ -121,9 +150,17 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||
[dump1090](https://github.com/flightaware/dump1090) |
|
||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[Celestrak](https://celestrak.org/)
|
||||
[Celestrak](https://celestrak.org/) |
|
||||
[Priyom.org](https://priyom.org/)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-04_e27bf619",
|
||||
"downloaded": "2026-01-07T14:55:20.680977Z"
|
||||
"version": "2026-01-11_fae1348c",
|
||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
||||
}
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
import sys
|
||||
import site
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
# Ensure user site-packages is available (may be disabled when running as root/sudo)
|
||||
if not site.ENABLE_USER_SITE:
|
||||
user_site = site.getusersitepackages()
|
||||
@@ -23,9 +25,9 @@ import subprocess
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request
|
||||
|
||||
from config import VERSION
|
||||
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 utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes
|
||||
from utils.sdr import SDRFactory
|
||||
@@ -34,20 +36,40 @@ from utils.constants import (
|
||||
MAX_AIRCRAFT_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
MAX_BT_DEVICE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
|
||||
import logging
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
# Track application start time for uptime calculation
|
||||
import time as _time
|
||||
_app_start_time = _time.time()
|
||||
|
||||
logger = logging.getLogger('intercept.database')
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||
|
||||
# Set up rate limiting
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address, # Identifies the user by their IP
|
||||
app=app,
|
||||
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
||||
)
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
# ============================================
|
||||
# ERROR HANDLERS
|
||||
# ============================================
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_handler(e):
|
||||
logger.warning(f"Rate limit exceeded for IP: {request.remote_addr}")
|
||||
flash("Too many login attempts. Please wait one minute before trying again.", "error")
|
||||
return render_template('login.html', version=VERSION), 429
|
||||
|
||||
# ============================================
|
||||
# SECURITY HEADERS
|
||||
@@ -69,6 +91,25 @@ def add_security_headers(response):
|
||||
return response
|
||||
|
||||
|
||||
# ============================================
|
||||
# CONTEXT PROCESSORS
|
||||
# ============================================
|
||||
|
||||
@app.context_processor
|
||||
def inject_offline_settings():
|
||||
"""Inject offline settings into all templates."""
|
||||
from utils.database import get_setting
|
||||
return {
|
||||
'offline_settings': {
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
# ============================================
|
||||
@@ -103,6 +144,37 @@ satellite_process = None
|
||||
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
satellite_lock = threading.Lock()
|
||||
|
||||
# ACARS aircraft messaging
|
||||
acars_process = None
|
||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
acars_lock = threading.Lock()
|
||||
|
||||
# APRS amateur radio tracking
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
aprs_lock = threading.Lock()
|
||||
|
||||
# RTLAMR utility meter reading
|
||||
rtlamr_process = None
|
||||
rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
rtlamr_lock = threading.Lock()
|
||||
|
||||
# AIS vessel tracking
|
||||
ais_process = None
|
||||
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
ais_lock = threading.Lock()
|
||||
|
||||
# DSC (Digital Selective Calling)
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dsc_lock = threading.Lock()
|
||||
|
||||
# TSCM (Technical Surveillance Countermeasures)
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE DICTIONARIES
|
||||
# ============================================
|
||||
@@ -126,6 +198,12 @@ bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested
|
||||
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
||||
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
||||
|
||||
# Vessel (AIS) state - using DataStore for automatic cleanup
|
||||
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
|
||||
|
||||
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
||||
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
||||
|
||||
# Satellite state
|
||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||
|
||||
@@ -135,21 +213,73 @@ cleanup_manager.register(wifi_clients)
|
||||
cleanup_manager.register(bt_devices)
|
||||
cleanup_manager.register(bt_beacons)
|
||||
cleanup_manager.register(adsb_aircraft)
|
||||
cleanup_manager.register(ais_vessels)
|
||||
cleanup_manager.register(dsc_messages)
|
||||
|
||||
|
||||
# ============================================
|
||||
# MAIN ROUTES
|
||||
# ============================================
|
||||
|
||||
@app.before_request
|
||||
def require_login():
|
||||
# Routes that don't require login (to avoid infinite redirect loop)
|
||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||
|
||||
# Controller API endpoints use API key auth, not session auth
|
||||
# Allow agent push/pull endpoints without session login
|
||||
if request.path.startswith('/controller/'):
|
||||
return None # Skip session check, controller routes handle their own auth
|
||||
|
||||
# If user is not logged in and the current route is not allowed...
|
||||
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('logged_in', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("5 per minute") # Limit to 5 login attempts per minute per IP
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
# Connect to DB and find user
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'SELECT password_hash, role FROM users WHERE username = ?',
|
||||
(username,)
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
|
||||
# Verify user exists and password is correct
|
||||
if user and check_password_hash(user['password_hash'], password):
|
||||
# Store data in session
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['role'] = user['role']
|
||||
|
||||
logger.info(f"User '{username}' logged in successfully.")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
logger.warning(f"Failed login attempt for username: {username}")
|
||||
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
||||
|
||||
return render_template('login.html', version=VERSION)
|
||||
|
||||
@app.route('/')
|
||||
def index() -> str:
|
||||
tools = {
|
||||
'rtl_fm': check_tool('rtl_fm'),
|
||||
'multimon': check_tool('multimon-ng'),
|
||||
'rtl_433': check_tool('rtl_433')
|
||||
'rtl_433': check_tool('rtl_433'),
|
||||
'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)
|
||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
|
||||
|
||||
|
||||
@app.route('/favicon.svg')
|
||||
@@ -164,6 +294,120 @@ def get_devices() -> Response:
|
||||
return jsonify([d.to_dict() for d in devices])
|
||||
|
||||
|
||||
@app.route('/devices/debug')
|
||||
def get_devices_debug() -> Response:
|
||||
"""Get detailed SDR device detection diagnostics."""
|
||||
import shutil
|
||||
|
||||
diagnostics = {
|
||||
'tools': {},
|
||||
'rtl_test': {},
|
||||
'soapy': {},
|
||||
'usb': {},
|
||||
'kernel_modules': {},
|
||||
'detected_devices': [],
|
||||
'suggestions': []
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
diagnostics['tools']['rtl_test'] = shutil.which('rtl_test') is not None
|
||||
diagnostics['tools']['SoapySDRUtil'] = shutil.which('SoapySDRUtil') is not None
|
||||
diagnostics['tools']['lsusb'] = shutil.which('lsusb') is not None
|
||||
|
||||
# Run rtl_test and capture full output
|
||||
if diagnostics['tools']['rtl_test']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
diagnostics['rtl_test'] = {
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||
}
|
||||
|
||||
# Check for common errors
|
||||
combined = (result.stdout or '') + (result.stderr or '')
|
||||
if 'No supported devices found' in combined:
|
||||
diagnostics['suggestions'].append('No RTL-SDR device detected. Check USB connection.')
|
||||
if 'usb_claim_interface error' in combined:
|
||||
diagnostics['suggestions'].append('Device busy - kernel DVB driver may have claimed it. Run: sudo modprobe -r dvb_usb_rtl28xxu')
|
||||
if 'Permission denied' in combined.lower():
|
||||
diagnostics['suggestions'].append('USB permission denied. Add udev rules or run as root.')
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
diagnostics['rtl_test'] = {'error': 'Timeout after 5 seconds'}
|
||||
except Exception as e:
|
||||
diagnostics['rtl_test'] = {'error': str(e)}
|
||||
else:
|
||||
diagnostics['suggestions'].append('rtl_test not found. Install rtl-sdr package.')
|
||||
|
||||
# Run SoapySDRUtil
|
||||
if diagnostics['tools']['SoapySDRUtil']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['SoapySDRUtil', '--find'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
diagnostics['soapy'] = {
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
diagnostics['soapy'] = {'error': 'Timeout after 10 seconds'}
|
||||
except Exception as e:
|
||||
diagnostics['soapy'] = {'error': str(e)}
|
||||
|
||||
# Check USB devices (Linux)
|
||||
if diagnostics['tools']['lsusb']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['lsusb'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
# Filter for common SDR vendor IDs
|
||||
sdr_vendors = ['0bda', '1d50', '1df7', '0403'] # Realtek, OpenMoko/HackRF, SDRplay, FTDI
|
||||
usb_lines = [l for l in result.stdout.split('\n')
|
||||
if any(v in l.lower() for v in sdr_vendors) or 'rtl' in l.lower() or 'sdr' in l.lower()]
|
||||
diagnostics['usb']['devices'] = usb_lines if usb_lines else ['No SDR-related USB devices found']
|
||||
except Exception as e:
|
||||
diagnostics['usb'] = {'error': str(e)}
|
||||
|
||||
# Check for loaded kernel modules that conflict (Linux)
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['lsmod'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
conflicting = ['dvb_usb_rtl28xxu', 'rtl2832', 'rtl2830']
|
||||
loaded = [m for m in conflicting if m in result.stdout]
|
||||
diagnostics['kernel_modules']['conflicting_loaded'] = loaded
|
||||
if loaded:
|
||||
diagnostics['suggestions'].append(f"Conflicting kernel modules loaded: {', '.join(loaded)}. Run: sudo modprobe -r {' '.join(loaded)}")
|
||||
except Exception as e:
|
||||
diagnostics['kernel_modules'] = {'error': str(e)}
|
||||
|
||||
# Get detected devices
|
||||
devices = SDRFactory.detect_devices()
|
||||
diagnostics['detected_devices'] = [d.to_dict() for d in devices]
|
||||
|
||||
if not devices and not diagnostics['suggestions']:
|
||||
diagnostics['suggestions'].append('No devices detected. Check USB connection and driver installation.')
|
||||
|
||||
return jsonify(diagnostics)
|
||||
|
||||
|
||||
@app.route('/dependencies')
|
||||
def get_dependencies() -> Response:
|
||||
"""Get status of all tool dependencies."""
|
||||
@@ -302,14 +546,20 @@ def health_check() -> Response:
|
||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'vessel_count': len(ais_vessels),
|
||||
'wifi_networks_count': len(wifi_networks),
|
||||
'wifi_clients_count': len(wifi_clients),
|
||||
'bt_devices_count': len(bt_devices),
|
||||
'dsc_messages_count': len(dsc_messages),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -317,16 +567,18 @@ def health_check() -> Response:
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder and WiFi processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
|
||||
|
||||
# Import adsb module to reset its state
|
||||
# Import adsb and ais modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
|
||||
killed = []
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090'
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -351,6 +603,25 @@ def kill_all() -> Response:
|
||||
adsb_process = None
|
||||
adsb_module.adsb_using_service = False
|
||||
|
||||
# Reset AIS state
|
||||
with ais_lock:
|
||||
ais_process = None
|
||||
ais_module.ais_running = False
|
||||
|
||||
# Reset ACARS state
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
|
||||
# Reset APRS state
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
|
||||
# Reset DSC state
|
||||
with dsc_lock:
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
|
||||
return jsonify({'status': 'killed', 'processes': killed})
|
||||
|
||||
|
||||
@@ -403,10 +674,32 @@ def main() -> None:
|
||||
|
||||
print("=" * 50)
|
||||
print(" INTERCEPT // Signal Intelligence")
|
||||
print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
|
||||
print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Check if running as root (required for WiFi monitor mode, some BT operations)
|
||||
import os
|
||||
if os.geteuid() != 0:
|
||||
print("\033[93m" + "=" * 50)
|
||||
print(" ⚠️ WARNING: Not running as root/sudo")
|
||||
print("=" * 50)
|
||||
print(" Some features require root privileges:")
|
||||
print(" - WiFi monitor mode and scanning")
|
||||
print(" - Bluetooth low-level operations")
|
||||
print(" - RTL-SDR access (on some systems)")
|
||||
print()
|
||||
print(" To run with full capabilities:")
|
||||
print(" sudo -E venv/bin/python intercept.py")
|
||||
print("=" * 50 + "\033[0m")
|
||||
print()
|
||||
# Store for API access
|
||||
app.config['RUNNING_AS_ROOT'] = False
|
||||
else:
|
||||
app.config['RUNNING_AS_ROOT'] = True
|
||||
print("Running as root - full capabilities enabled")
|
||||
print()
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
|
||||
@@ -441,4 +734,4 @@ def main() -> None:
|
||||
debug=args.debug,
|
||||
threaded=True,
|
||||
load_dotenv=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# DSC (Digital Selective Calling) decoder wrapper
|
||||
# Invokes the Python DSC decoder module
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Set PYTHONPATH to include project root
|
||||
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
|
||||
|
||||
# Run the decoder module
|
||||
exec python3 -m utils.dsc.decoder "$@"
|
||||
@@ -7,7 +7,71 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.9.0"
|
||||
VERSION = "2.11.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.11.0",
|
||||
"date": "January 2026",
|
||||
"highlights": [
|
||||
"Meshtastic LoRa mesh network integration",
|
||||
"Ubertooth One BLE scanning support",
|
||||
"Offline mode with bundled assets",
|
||||
"Settings modal with tile provider configuration",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.10.0",
|
||||
"date": "January 2026",
|
||||
"highlights": [
|
||||
"AIS vessel tracking with VHF DSC distress monitoring",
|
||||
"Spy Stations database (number stations & diplomatic HF)",
|
||||
"MMSI country identification and distress alert overlays",
|
||||
"SDR device conflict detection for AIS/DSC",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.9.5",
|
||||
"date": "January 2026",
|
||||
"highlights": [
|
||||
"Enhanced TSCM with MAC-randomization resistant detection",
|
||||
"Clickable score cards and device detail expansion",
|
||||
"RF scanning improvements with status feedback",
|
||||
"Root privilege check and warning display",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.9.0",
|
||||
"date": "January 2026",
|
||||
"highlights": [
|
||||
"New dropdown navigation menus for cleaner UI",
|
||||
"TSCM baseline recording now captures device data",
|
||||
"Device identity engine integration for threat detection",
|
||||
"Welcome screen with mode selection",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.8.0",
|
||||
"date": "December 2025",
|
||||
"highlights": [
|
||||
"Added TSCM counter-surveillance mode",
|
||||
"WiFi/Bluetooth device correlation engine",
|
||||
"Tracker detection (AirTag, Tile, SmartTag)",
|
||||
"Risk scoring and threat classification",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.7.0",
|
||||
"date": "November 2025",
|
||||
"highlights": [
|
||||
"Multi-SDR hardware support via SoapySDR",
|
||||
"LimeSDR, HackRF, Airspy, SDRplay support",
|
||||
"Improved aircraft database with photo lookup",
|
||||
"GPS auto-detection and integration",
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _get_env(key: str, default: str) -> str:
|
||||
@@ -75,12 +139,24 @@ 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_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)
|
||||
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# Admin credentials
|
||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure application logging."""
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
TSCM (Technical Surveillance Countermeasures) Frequency Database
|
||||
|
||||
Known surveillance device frequencies, sweep presets, and threat signatures
|
||||
for counter-surveillance operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# =============================================================================
|
||||
# Known Surveillance Frequencies (MHz)
|
||||
# =============================================================================
|
||||
|
||||
SURVEILLANCE_FREQUENCIES = {
|
||||
'wireless_mics': [
|
||||
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
|
||||
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
|
||||
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
|
||||
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
|
||||
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
|
||||
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'wireless_cameras': [
|
||||
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
|
||||
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
|
||||
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
|
||||
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'gps_trackers': [
|
||||
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
|
||||
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
|
||||
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
|
||||
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
|
||||
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'body_worn': [
|
||||
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
|
||||
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
|
||||
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
|
||||
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
|
||||
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
|
||||
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'common_bugs': [
|
||||
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
|
||||
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
|
||||
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
|
||||
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
|
||||
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
|
||||
],
|
||||
|
||||
'ism_bands': [
|
||||
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
|
||||
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
|
||||
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
|
||||
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
|
||||
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
|
||||
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
|
||||
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sweep Presets
|
||||
# =============================================================================
|
||||
|
||||
SWEEP_PRESETS = {
|
||||
'quick': {
|
||||
'name': 'Quick Scan',
|
||||
'description': 'Fast 2-minute check of most common bug frequencies',
|
||||
'duration_seconds': 120,
|
||||
'ranges': [
|
||||
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
|
||||
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'standard': {
|
||||
'name': 'Standard Sweep',
|
||||
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
|
||||
'duration_seconds': 300,
|
||||
'ranges': [
|
||||
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
|
||||
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
|
||||
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
|
||||
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'full': {
|
||||
'name': 'Full Spectrum',
|
||||
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
|
||||
'duration_seconds': 900,
|
||||
'ranges': [
|
||||
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'wireless_cameras': {
|
||||
'name': 'Wireless Cameras',
|
||||
'description': 'Focus on video transmission frequencies',
|
||||
'duration_seconds': 180,
|
||||
'ranges': [
|
||||
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
|
||||
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
|
||||
],
|
||||
'wifi': True, # WiFi cameras
|
||||
'bluetooth': False,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'body_worn': {
|
||||
'name': 'Body-Worn Devices',
|
||||
'description': 'Detect body wires and covert transmitters',
|
||||
'duration_seconds': 240,
|
||||
'ranges': [
|
||||
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
|
||||
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
|
||||
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
|
||||
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
|
||||
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
|
||||
],
|
||||
'wifi': False,
|
||||
'bluetooth': True, # BLE bugs
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'gps_trackers': {
|
||||
'name': 'GPS Trackers',
|
||||
'description': 'Detect cellular-based GPS tracking devices',
|
||||
'duration_seconds': 180,
|
||||
'ranges': [
|
||||
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
|
||||
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
|
||||
],
|
||||
'wifi': False,
|
||||
'bluetooth': True, # BLE trackers
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'bluetooth_only': {
|
||||
'name': 'Bluetooth/BLE Trackers',
|
||||
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
|
||||
'duration_seconds': 60,
|
||||
'ranges': [],
|
||||
'wifi': False,
|
||||
'bluetooth': True,
|
||||
'rf': False,
|
||||
},
|
||||
|
||||
'wifi_only': {
|
||||
'name': 'WiFi Devices',
|
||||
'description': 'Scan for hidden WiFi cameras and access points',
|
||||
'duration_seconds': 60,
|
||||
'ranges': [],
|
||||
'wifi': True,
|
||||
'bluetooth': False,
|
||||
'rf': False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Known Tracker Signatures
|
||||
# =============================================================================
|
||||
|
||||
BLE_TRACKER_SIGNATURES = {
|
||||
'apple_airtag': {
|
||||
'name': 'Apple AirTag',
|
||||
'company_id': 0x004C,
|
||||
'patterns': ['findmy', 'airtag'],
|
||||
'risk': 'high',
|
||||
'description': 'Apple Find My network tracker',
|
||||
},
|
||||
'tile': {
|
||||
'name': 'Tile Tracker',
|
||||
'company_id': 0x00ED,
|
||||
'patterns': ['tile'],
|
||||
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
|
||||
'risk': 'high',
|
||||
'description': 'Tile Bluetooth tracker',
|
||||
},
|
||||
'samsung_smarttag': {
|
||||
'name': 'Samsung SmartTag',
|
||||
'company_id': 0x0075,
|
||||
'patterns': ['smarttag', 'smartthings'],
|
||||
'risk': 'high',
|
||||
'description': 'Samsung SmartThings tracker',
|
||||
},
|
||||
'chipolo': {
|
||||
'name': 'Chipolo',
|
||||
'company_id': 0x0A09,
|
||||
'patterns': ['chipolo'],
|
||||
'risk': 'high',
|
||||
'description': 'Chipolo Bluetooth tracker',
|
||||
},
|
||||
'generic_beacon': {
|
||||
'name': 'Unknown BLE Beacon',
|
||||
'company_id': None,
|
||||
'patterns': [],
|
||||
'risk': 'medium',
|
||||
'description': 'Unidentified BLE beacon device',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Threat Classification
|
||||
# =============================================================================
|
||||
|
||||
THREAT_TYPES = {
|
||||
'new_device': {
|
||||
'name': 'New Device',
|
||||
'description': 'Device not present in baseline',
|
||||
'default_severity': 'medium',
|
||||
},
|
||||
'tracker': {
|
||||
'name': 'Tracking Device',
|
||||
'description': 'Known BLE tracker detected',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'unknown_signal': {
|
||||
'name': 'Unknown Signal',
|
||||
'description': 'Unidentified RF transmission',
|
||||
'default_severity': 'medium',
|
||||
},
|
||||
'burst_transmission': {
|
||||
'name': 'Burst Transmission',
|
||||
'description': 'Intermittent/store-and-forward signal detected',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'hidden_camera': {
|
||||
'name': 'Potential Hidden Camera',
|
||||
'description': 'WiFi camera or video transmitter detected',
|
||||
'default_severity': 'critical',
|
||||
},
|
||||
'gsm_bug': {
|
||||
'name': 'GSM/Cellular Bug',
|
||||
'description': 'Cellular transmission in non-phone device context',
|
||||
'default_severity': 'critical',
|
||||
},
|
||||
'rogue_ap': {
|
||||
'name': 'Rogue Access Point',
|
||||
'description': 'Unauthorized WiFi access point',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'anomaly': {
|
||||
'name': 'Signal Anomaly',
|
||||
'description': 'Unusual signal pattern or behavior',
|
||||
'default_severity': 'low',
|
||||
},
|
||||
}
|
||||
|
||||
SEVERITY_LEVELS = {
|
||||
'critical': {
|
||||
'level': 4,
|
||||
'color': '#ff0000',
|
||||
'description': 'Immediate action required - active surveillance likely',
|
||||
},
|
||||
'high': {
|
||||
'level': 3,
|
||||
'color': '#ff6600',
|
||||
'description': 'Strong indicator of surveillance device',
|
||||
},
|
||||
'medium': {
|
||||
'level': 2,
|
||||
'color': '#ffcc00',
|
||||
'description': 'Potential threat - requires investigation',
|
||||
},
|
||||
'low': {
|
||||
'level': 1,
|
||||
'color': '#00cc00',
|
||||
'description': 'Minor anomaly - low probability of threat',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WiFi Camera Detection Patterns
|
||||
# =============================================================================
|
||||
|
||||
WIFI_CAMERA_PATTERNS = {
|
||||
'ssid_patterns': [
|
||||
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
|
||||
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
|
||||
'arlo', 'nest', 'blink', 'eufy', 'yi',
|
||||
],
|
||||
'oui_manufacturers': [
|
||||
'Hikvision',
|
||||
'Dahua',
|
||||
'Axis Communications',
|
||||
'Hanwha Techwin',
|
||||
'Vivotek',
|
||||
'Ubiquiti',
|
||||
'Wyze Labs',
|
||||
'Amazon Technologies', # Ring
|
||||
'Google', # Nest
|
||||
],
|
||||
'mac_prefixes': {
|
||||
'C0:25:E9': 'TP-Link Camera',
|
||||
'A4:DA:22': 'TP-Link Camera',
|
||||
'78:8C:B5': 'TP-Link Camera',
|
||||
'D4:6E:0E': 'TP-Link Camera',
|
||||
'2C:AA:8E': 'Wyze Camera',
|
||||
'AC:CF:85': 'Hikvision',
|
||||
'54:C4:15': 'Hikvision',
|
||||
'C0:56:E3': 'Hikvision',
|
||||
'3C:EF:8C': 'Dahua',
|
||||
'A0:BD:1D': 'Dahua',
|
||||
'E4:24:6C': 'Dahua',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
||||
"""
|
||||
Determine the risk level for a given frequency.
|
||||
|
||||
Returns:
|
||||
Tuple of (risk_level, category_name)
|
||||
"""
|
||||
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
||||
for freq_range in ranges:
|
||||
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||
return freq_range['risk'], freq_range['name']
|
||||
|
||||
return 'low', 'Unknown Band'
|
||||
|
||||
|
||||
def get_sweep_preset(preset_name: str) -> dict | None:
|
||||
"""Get a sweep preset by name."""
|
||||
return SWEEP_PRESETS.get(preset_name)
|
||||
|
||||
|
||||
def get_all_sweep_presets() -> dict:
|
||||
"""Get all available sweep presets."""
|
||||
return {
|
||||
name: {
|
||||
'name': preset['name'],
|
||||
'description': preset['description'],
|
||||
'duration_seconds': preset['duration_seconds'],
|
||||
}
|
||||
for name, preset in SWEEP_PRESETS.items()
|
||||
}
|
||||
|
||||
|
||||
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | None = None) -> dict | None:
|
||||
"""
|
||||
Check if a BLE device matches known tracker signatures.
|
||||
|
||||
Args:
|
||||
device_name: Device name to check against patterns
|
||||
manufacturer_data: Manufacturer data as bytes or hex string
|
||||
|
||||
Returns:
|
||||
Tracker info dict if match found, None otherwise
|
||||
"""
|
||||
if device_name:
|
||||
name_lower = device_name.lower()
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
for pattern in tracker_info.get('patterns', []):
|
||||
if pattern in name_lower:
|
||||
return tracker_info
|
||||
|
||||
if manufacturer_data:
|
||||
# Convert hex string to bytes if needed
|
||||
mfr_bytes = manufacturer_data
|
||||
if isinstance(manufacturer_data, str):
|
||||
try:
|
||||
mfr_bytes = bytes.fromhex(manufacturer_data)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if len(mfr_bytes) >= 2:
|
||||
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
if tracker_info.get('company_id') == company_id:
|
||||
return tracker_info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
|
||||
"""Check if a WiFi device might be a hidden camera."""
|
||||
if ssid:
|
||||
ssid_lower = ssid.lower()
|
||||
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
|
||||
if pattern in ssid_lower:
|
||||
return True
|
||||
|
||||
if mac:
|
||||
mac_prefix = mac[:8].upper()
|
||||
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
|
||||
return True
|
||||
|
||||
if vendor:
|
||||
vendor_lower = vendor.lower()
|
||||
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
|
||||
if manufacturer.lower() in vendor_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
|
||||
"""
|
||||
Determine threat severity based on type and context.
|
||||
|
||||
Args:
|
||||
threat_type: Type of threat from THREAT_TYPES
|
||||
context: Optional context dict with signal_strength, etc.
|
||||
|
||||
Returns:
|
||||
Severity level string
|
||||
"""
|
||||
threat_info = THREAT_TYPES.get(threat_type, {})
|
||||
base_severity = threat_info.get('default_severity', 'medium')
|
||||
|
||||
if context:
|
||||
# Upgrade severity based on signal strength (closer = more concerning)
|
||||
signal = context.get('signal_strength')
|
||||
if signal and signal > -50: # Very strong signal
|
||||
if base_severity == 'medium':
|
||||
return 'high'
|
||||
elif base_severity == 'high':
|
||||
return 'critical'
|
||||
|
||||
return base_severity
|
||||
@@ -1,5 +1,11 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker Compose configuration for easy deployment
|
||||
#
|
||||
# Basic usage:
|
||||
# docker compose up -d
|
||||
#
|
||||
# With ADS-B history (Postgres):
|
||||
# docker compose --profile history up -d
|
||||
|
||||
services:
|
||||
intercept:
|
||||
@@ -13,15 +19,23 @@ services:
|
||||
# USB device mapping (alternative to privileged mode)
|
||||
# devices:
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
# volumes:
|
||||
# Persist data directory
|
||||
- ./data:/app/data
|
||||
# - ./data:/app/data
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
# ADS-B history is disabled by default
|
||||
# To enable, use: docker compose --profile history up -d
|
||||
# - 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
|
||||
# Network mode for WiFi scanning (requires host network)
|
||||
# network_mode: host
|
||||
restart: unless-stopped
|
||||
@@ -32,6 +46,54 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# ADS-B history with Postgres persistence
|
||||
# Enable with: docker compose --profile history up -d
|
||||
intercept-history:
|
||||
build: .
|
||||
container_name: intercept
|
||||
profiles:
|
||||
- history
|
||||
depends_on:
|
||||
- adsb_db
|
||||
ports:
|
||||
- "5050:5050"
|
||||
privileged: true
|
||||
environment:
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
- 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
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
adsb_db:
|
||||
image: postgres:16-alpine
|
||||
container_name: intercept-adsb-db
|
||||
profiles:
|
||||
- history
|
||||
environment:
|
||||
- POSTGRES_DB=intercept_adsb
|
||||
- POSTGRES_USER=intercept
|
||||
- POSTGRES_PASSWORD=intercept
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Optional: Add volume for persistent SQLite database
|
||||
# volumes:
|
||||
# intercept-data:
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
# Intercept Distributed Agent System
|
||||
|
||||
This document describes the distributed agent architecture that allows multiple remote sensor nodes to feed data into a central Intercept controller.
|
||||
|
||||
## Overview
|
||||
|
||||
The agent system uses a hub-and-spoke architecture where:
|
||||
- **Controller**: The main Intercept instance that aggregates data from multiple agents
|
||||
- **Agents**: Lightweight sensor nodes running on remote devices with SDR hardware
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ INTERCEPT CONTROLLER │
|
||||
│ (port 5050) │
|
||||
│ │
|
||||
│ - Web UI with agent selector │
|
||||
│ - /controller/manage page │
|
||||
│ - Multi-agent SSE stream │
|
||||
│ - Push data storage │
|
||||
└─────────────────────────────────┘
|
||||
▲ ▲ ▲
|
||||
│ │ │
|
||||
Push/Pull │ │ │ Push/Pull
|
||||
│ │ │
|
||||
┌────┴───┐ ┌────┴───┐ ┌────┴───┐
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ :8020 │ │ :8020 │ │ :8020 │
|
||||
│ │ │ │ │ │
|
||||
│[RTL-SDR] │[HackRF] │ │[LimeSDR]
|
||||
└────────┘ └────────┘ └────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Controller
|
||||
|
||||
The controller is the main Intercept application:
|
||||
|
||||
```bash
|
||||
cd intercept
|
||||
python app.py
|
||||
# Runs on http://localhost:5050
|
||||
```
|
||||
|
||||
### 2. Configure an Agent
|
||||
|
||||
Create a config file on the remote machine:
|
||||
|
||||
```ini
|
||||
# intercept_agent.cfg
|
||||
[agent]
|
||||
name = sensor-node-1
|
||||
port = 8020
|
||||
allowed_ips =
|
||||
allow_cors = false
|
||||
|
||||
[controller]
|
||||
url = http://192.168.1.100:5050
|
||||
api_key = your-secret-key-here
|
||||
push_enabled = true
|
||||
push_interval = 5
|
||||
|
||||
[modes]
|
||||
pager = true
|
||||
sensor = true
|
||||
adsb = true
|
||||
wifi = true
|
||||
bluetooth = true
|
||||
```
|
||||
|
||||
### 3. Start the Agent
|
||||
|
||||
```bash
|
||||
python intercept_agent.py --config intercept_agent.cfg
|
||||
# Runs on http://localhost:8020
|
||||
```
|
||||
|
||||
### 4. Register the Agent
|
||||
|
||||
Go to `http://controller:5050/controller/manage` and add the agent:
|
||||
- **Name**: sensor-node-1 (must match config)
|
||||
- **Base URL**: http://agent-ip:8020
|
||||
- **API Key**: your-secret-key-here (must match config)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
The system supports two data flow patterns:
|
||||
|
||||
#### Push (Agent → Controller)
|
||||
|
||||
Agents automatically push captured data to the controller:
|
||||
|
||||
1. Agent captures data (e.g., rtl_433 sensor readings)
|
||||
2. Data is queued in the `ControllerPushClient`
|
||||
3. Agent POSTs to `http://controller/controller/api/ingest`
|
||||
4. Controller validates API key and stores in `push_payloads` table
|
||||
5. Data is available via SSE stream at `/controller/stream/all`
|
||||
|
||||
```
|
||||
Agent Controller
|
||||
│ │
|
||||
│ POST /controller/api/ingest │
|
||||
│ Header: X-API-Key: secret │
|
||||
│ Body: {agent_name, scan_type, │
|
||||
│ payload, timestamp} │
|
||||
│ ──────────────────────────────► │
|
||||
│ │
|
||||
│ 200 OK │
|
||||
│ ◄────────────────────────────── │
|
||||
```
|
||||
|
||||
#### Pull (Controller → Agent)
|
||||
|
||||
The controller can also pull data on-demand:
|
||||
|
||||
1. User selects agent in UI dropdown
|
||||
2. User clicks "Start Listening"
|
||||
3. Controller proxies request to agent
|
||||
4. Agent starts the mode and returns status
|
||||
5. Controller polls agent for data
|
||||
|
||||
```
|
||||
Browser Controller Agent
|
||||
│ │ │
|
||||
│ POST /controller/ │ │
|
||||
│ agents/1/sensor/start│ │
|
||||
│ ─────────────────────► │ │
|
||||
│ │ POST /sensor/start │
|
||||
│ │ ────────────────────────► │
|
||||
│ │ │
|
||||
│ │ {status: started} │
|
||||
│ │ ◄──────────────────────── │
|
||||
│ {status: success} │ │
|
||||
│ ◄───────────────────── │ │
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
API key authentication secures the push mechanism:
|
||||
|
||||
1. Agent config specifies `api_key` in `[controller]` section
|
||||
2. Agent sends `X-API-Key` header with each push request
|
||||
3. Controller looks up agent by name in database
|
||||
4. Controller compares provided key with stored key
|
||||
5. Mismatched keys return 401 Unauthorized
|
||||
|
||||
### Database Schema
|
||||
|
||||
Two tables support the agent system:
|
||||
|
||||
```sql
|
||||
-- Registered agents
|
||||
CREATE TABLE agents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
capabilities TEXT, -- JSON: {pager: true, sensor: true, ...}
|
||||
interfaces TEXT, -- JSON: {devices: [...]}
|
||||
gps_coords TEXT, -- JSON: {lat, lon}
|
||||
last_seen TIMESTAMP,
|
||||
is_active BOOLEAN
|
||||
);
|
||||
|
||||
-- Pushed data from agents
|
||||
CREATE TABLE push_payloads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
agent_id INTEGER,
|
||||
scan_type TEXT, -- pager, sensor, adsb, wifi, etc.
|
||||
payload TEXT, -- JSON data
|
||||
received_at TIMESTAMP,
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
);
|
||||
```
|
||||
|
||||
## Agent REST API
|
||||
|
||||
The agent exposes these endpoints:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Health check (returns `{status: "healthy"}`) |
|
||||
| `/capabilities` | GET | Available modes, devices, GPS status |
|
||||
| `/status` | GET | Running modes, uptime, push status |
|
||||
| `/{mode}/start` | POST | Start a mode (pager, sensor, adsb, etc.) |
|
||||
| `/{mode}/stop` | POST | Stop a mode |
|
||||
| `/{mode}/status` | GET | Mode-specific status |
|
||||
| `/{mode}/data` | GET | Current data snapshot |
|
||||
|
||||
### Example: Start Sensor Mode
|
||||
|
||||
```bash
|
||||
curl -X POST http://agent:8020/sensor/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"frequency": 433.92, "device_index": 0}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "started",
|
||||
"mode": "sensor",
|
||||
"command": "/usr/local/bin/rtl_433 -d 0 -f 433.92M -F json",
|
||||
"gps_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Get Capabilities
|
||||
|
||||
```bash
|
||||
curl http://agent:8020/capabilities
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"modes": {
|
||||
"pager": true,
|
||||
"sensor": true,
|
||||
"adsb": true,
|
||||
"wifi": true,
|
||||
"bluetooth": true
|
||||
},
|
||||
"devices": [
|
||||
{
|
||||
"index": 0,
|
||||
"name": "RTLSDRBlog, Blog V4",
|
||||
"sdr_type": "rtlsdr",
|
||||
"capabilities": {
|
||||
"freq_min_mhz": 24.0,
|
||||
"freq_max_mhz": 1766.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"gps": true,
|
||||
"gps_position": {
|
||||
"lat": 33.543,
|
||||
"lon": -82.194,
|
||||
"altitude": 70.0
|
||||
},
|
||||
"tool_details": {
|
||||
"sensor": {
|
||||
"name": "433MHz Sensors",
|
||||
"ready": true,
|
||||
"tools": {
|
||||
"rtl_433": {"installed": true, "required": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Modes
|
||||
|
||||
All modes are fully implemented in the agent with the following tools and data formats:
|
||||
|
||||
| Mode | Tool(s) | Data Format | Notes |
|
||||
|------|---------|-------------|-------|
|
||||
| `sensor` | rtl_433 | JSON readings | ISM band devices (433/868/915 MHz) |
|
||||
| `pager` | rtl_fm + multimon-ng | POCSAG/FLEX messages | Address, function, message content |
|
||||
| `adsb` | dump1090 | SBS-format aircraft | ICAO, callsign, position, altitude |
|
||||
| `ais` | AIS-catcher | JSON vessels | MMSI, position, speed, vessel info |
|
||||
| `acars` | acarsdec | JSON messages | Aircraft tail, label, message text |
|
||||
| `aprs` | rtl_fm + direwolf | APRS packets | Callsign, position, path |
|
||||
| `wifi` | airodump-ng | Networks + clients | BSSID, ESSID, signal, clients |
|
||||
| `bluetooth` | bluetoothctl | Device list | MAC, name, RSSI |
|
||||
| `rtlamr` | rtl_tcp + rtlamr | Meter readings | Meter ID, consumption data |
|
||||
| `dsc` | rtl_fm (+ dsc-decoder) | DSC messages | MMSI, distress category, position |
|
||||
| `tscm` | WiFi/BT analysis | Anomaly reports | New/rogue devices detected |
|
||||
| `satellite` | skyfield (TLE) | Pass predictions | No SDR required |
|
||||
| `listening_post` | rtl_fm scanner | Signal detections | Frequency, modulation |
|
||||
|
||||
### Mode-Specific Notes
|
||||
|
||||
**Listening Post**: Full FFT streaming isn't practical over HTTP. Instead, the agent provides:
|
||||
- Signal detection events when activity is found
|
||||
- Current scanning frequency
|
||||
- Activity log of detected signals
|
||||
|
||||
**TSCM**: Analyzes WiFi and Bluetooth data for anomalies:
|
||||
- Builds baseline of known devices
|
||||
- Reports new/unknown devices as anomalies
|
||||
- No SDR required (uses WiFi/BT data)
|
||||
|
||||
**Satellite**: Pure computational mode:
|
||||
- Calculates pass predictions from TLE data
|
||||
- Requires observer location (lat/lon)
|
||||
- No SDR required
|
||||
|
||||
**Audio Modes**: Modes requiring real-time audio (airband, listening_post audio) are limited via agents. Use rtl_tcp for remote audio streaming instead.
|
||||
|
||||
## Controller API
|
||||
|
||||
### Agent Management
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/controller/agents` | GET | List all agents |
|
||||
| `/controller/agents` | POST | Register new agent |
|
||||
| `/controller/agents/{id}` | GET | Get agent details |
|
||||
| `/controller/agents/{id}` | DELETE | Remove agent |
|
||||
| `/controller/agents/{id}?refresh=true` | GET | Refresh agent capabilities |
|
||||
|
||||
### Proxy Operations
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/controller/agents/{id}/{mode}/start` | POST | Start mode on agent |
|
||||
| `/controller/agents/{id}/{mode}/stop` | POST | Stop mode on agent |
|
||||
| `/controller/agents/{id}/{mode}/data` | GET | Get data from agent |
|
||||
|
||||
### Push Ingestion
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/controller/api/ingest` | POST | Receive pushed data from agents |
|
||||
|
||||
### SSE Streams
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `/controller/stream/all` | Combined stream from all agents |
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### Agent Selector
|
||||
|
||||
The main UI includes an agent dropdown in supported modes:
|
||||
|
||||
```html
|
||||
<select id="agentSelect">
|
||||
<option value="local">Local (This Device)</option>
|
||||
<option value="1">● sensor-node-1</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
When an agent is selected:
|
||||
1. Device list updates to show agent's SDR devices
|
||||
2. Start/Stop commands route through controller proxy
|
||||
3. Data displays with agent name badge
|
||||
|
||||
### Multi-Agent Mode
|
||||
|
||||
Enable "Show All Agents" checkbox to:
|
||||
- Connect to `/controller/stream/all` SSE
|
||||
- Display combined data from all agents
|
||||
- Show agent name badge on each data item
|
||||
|
||||
## GPS Integration
|
||||
|
||||
Agents can include GPS coordinates with captured data:
|
||||
|
||||
1. Agent connects to local `gpsd` daemon
|
||||
2. GPS position included in `/capabilities` and `/status`
|
||||
3. Each data snapshot includes `agent_gps` field
|
||||
4. Controller can use GPS for trilateration (multiple agents)
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Agent Config (`intercept_agent.cfg`)
|
||||
|
||||
```ini
|
||||
[agent]
|
||||
# Agent identity (must be unique across all agents)
|
||||
name = sensor-node-1
|
||||
|
||||
# Port to listen on
|
||||
port = 8020
|
||||
|
||||
# Restrict connections to specific IPs (comma-separated, empty = all)
|
||||
allowed_ips =
|
||||
|
||||
# Enable CORS headers
|
||||
allow_cors = false
|
||||
|
||||
[controller]
|
||||
# Controller URL (required for push)
|
||||
url = http://192.168.1.100:5050
|
||||
|
||||
# API key for authentication
|
||||
api_key = your-secret-key
|
||||
|
||||
# Enable automatic data push
|
||||
push_enabled = true
|
||||
|
||||
# Push interval in seconds
|
||||
push_interval = 5
|
||||
|
||||
[modes]
|
||||
# Enable/disable specific modes
|
||||
pager = true
|
||||
sensor = true
|
||||
adsb = true
|
||||
ais = true
|
||||
wifi = true
|
||||
bluetooth = true
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent not appearing in controller
|
||||
|
||||
1. Check agent is running: `curl http://agent:8020/health`
|
||||
2. Verify agent is registered in `/controller/manage`
|
||||
3. Check API key matches between agent config and controller registration
|
||||
4. Check network connectivity between agent and controller
|
||||
|
||||
### Push data not arriving
|
||||
|
||||
1. Check agent status: `curl http://agent:8020/status`
|
||||
- Verify `push_enabled: true` and `push_connected: true`
|
||||
2. Check controller logs for authentication errors
|
||||
3. Verify API key matches
|
||||
4. Check if mode is running and producing data
|
||||
|
||||
### Mode won't start on agent
|
||||
|
||||
1. Check capabilities: `curl http://agent:8020/capabilities`
|
||||
2. Verify required tools are installed (check `tool_details`)
|
||||
3. Check if SDR device is available (not in use by another process)
|
||||
|
||||
### No data from sensor mode
|
||||
|
||||
1. Verify rtl_433 is running: `ps aux | grep rtl_433`
|
||||
2. Check sensor status: `curl http://agent:8020/sensor/status`
|
||||
3. Note: Empty data is normal if no 433MHz devices are transmitting nearby
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **API Keys**: Always use strong, unique API keys for each agent
|
||||
2. **Network**: Consider running agents on a private network or VPN
|
||||
3. **HTTPS**: For production, use HTTPS between agents and controller
|
||||
4. **Firewall**: Restrict agent ports to controller IP only
|
||||
5. **allowed_ips**: Use this config option to restrict agent connections
|
||||
|
||||
## Dashboard Integration
|
||||
|
||||
Agent support has been integrated into the following specialized dashboards:
|
||||
|
||||
### ADS-B Dashboard (`/adsb/dashboard`)
|
||||
- Agent selector in header bar
|
||||
- Routes tracking start/stop through agent proxy when remote agent selected
|
||||
- Connects to multi-agent stream for data from remote agents
|
||||
- Displays agent badge on aircraft from remote sources
|
||||
- Updates observer location from agent's GPS coordinates
|
||||
|
||||
### AIS Dashboard (`/ais/dashboard`)
|
||||
- Agent selector in header bar
|
||||
- Routes AIS and DSC mode operations through agent proxy
|
||||
- Connects to multi-agent stream for vessel data
|
||||
- Displays agent badge on vessels from remote sources
|
||||
- Updates observer location from agent's GPS coordinates
|
||||
|
||||
### Main Dashboard (`/`)
|
||||
- Agent selector in sidebar
|
||||
- Supports sensor, pager, WiFi, Bluetooth modes via agents
|
||||
- SDR conflict detection with device-aware warnings
|
||||
- Real-time sync with agent's running mode state
|
||||
|
||||
### Multi-SDR Agent Support
|
||||
|
||||
For agents with multiple SDR devices, the system now tracks which device each mode is using:
|
||||
|
||||
```json
|
||||
{
|
||||
"running_modes": ["sensor", "adsb"],
|
||||
"running_modes_detail": {
|
||||
"sensor": {"device": 0, "started_at": "2024-01-15T10:30:00Z"},
|
||||
"adsb": {"device": 1, "started_at": "2024-01-15T10:35:00Z"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This allows:
|
||||
- Smart conflict detection (only warns if same device is in use)
|
||||
- Display of which device each mode is using
|
||||
- Parallel operation of multiple SDR modes on multi-SDR agents
|
||||
|
||||
### Agent Mode Warnings
|
||||
|
||||
When an agent has SDR modes running, the UI displays:
|
||||
- Warning banner showing active modes with device numbers
|
||||
- Stop buttons for each running mode
|
||||
- Refresh button to re-sync with agent state
|
||||
|
||||
### Pages Without Agent Support
|
||||
|
||||
The following pages don't require SDR-based agent support:
|
||||
- **Satellite Dashboard** (`/satellite/dashboard`) - Uses TLE orbital calculations, no SDR
|
||||
- **History pages** - Display stored data, not live SDR streams
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `intercept_agent.py` | Standalone agent server |
|
||||
| `intercept_agent.cfg` | Agent configuration template |
|
||||
| `routes/controller.py` | Controller API blueprint |
|
||||
| `utils/agent_client.py` | HTTP client for agents |
|
||||
| `utils/database.py` | Agent CRUD operations |
|
||||
| `static/js/core/agents.js` | Frontend agent management |
|
||||
| `templates/agents.html` | Agent management page |
|
||||
| `templates/adsb_dashboard.html` | ADS-B page with agent integration |
|
||||
| `templates/ais_dashboard.html` | AIS page with agent integration |
|
||||
@@ -16,6 +16,28 @@ Complete feature list for all modules.
|
||||
- **Doorbells, remotes, and IoT devices**
|
||||
- **Smart meters** and utility monitors
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||
- **Full-screen dashboard** - dedicated popout with interactive map
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
||||
- **Navigation data** - speed, course, heading, rate of turn
|
||||
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
||||
- **Vessel dimensions** - length, width, draught
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## Spy Stations (Number Stations)
|
||||
|
||||
- **Comprehensive database** of active number stations and diplomatic networks
|
||||
- **Station profiles** - frequencies, schedules, operators, descriptions
|
||||
- **Filter by type** - number stations vs diplomatic networks
|
||||
- **Filter by country** - Russia, Cuba, Israel, Poland, North Korea, etc.
|
||||
- **Filter by mode** - USB, AM, CW, OFDM
|
||||
- **Tune integration** - click to tune Listening Post to station frequency
|
||||
- **Source links** - references to priyom.org for detailed information
|
||||
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
|
||||
|
||||
## ADS-B Aircraft Tracking
|
||||
|
||||
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
||||
@@ -26,6 +48,8 @@ Complete feature list for all modules.
|
||||
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
||||
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
||||
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
||||
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
|
||||
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
|
||||
- **Observer location** - manual input or GPS geolocation
|
||||
- **Audio alerts** - notifications for military and emergency aircraft
|
||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||
@@ -35,6 +59,31 @@ Complete feature list for all modules.
|
||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
- **Real-time vessel tracking** via AIS-catcher or rtl_ais
|
||||
- **Full-screen dashboard** - dedicated popout with maritime map
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Vessel trails** - optional track history visualization
|
||||
- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
|
||||
- **Country identification** - flag lookup via Maritime Identification Digits (MID)
|
||||
|
||||
### VHF DSC Channel 70 Monitoring
|
||||
|
||||
Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
|
||||
|
||||
- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
|
||||
- **MMSI country lookup** - 180+ Maritime Identification Digit codes
|
||||
- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
|
||||
- **Position extraction** - Automatic lat/lon parsing from distress messages
|
||||
- **Map markers** - Distress positions plotted with pulsing alert markers
|
||||
- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
|
||||
- **Audio alerts** - Notification sound for critical messages
|
||||
- **Alert persistence** - Critical alerts stored permanently in database
|
||||
- **Acknowledgement workflow** - Track response status with notes
|
||||
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -75,13 +124,119 @@ Complete feature list for all modules.
|
||||
## Bluetooth Scanning
|
||||
|
||||
- **BLE and Classic** Bluetooth device scanning
|
||||
- **Multiple scan modes** - hcitool, bluetoothctl
|
||||
- **Multiple scan modes** - hcitool, bluetoothctl, bleak
|
||||
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
||||
- **Device classification** - phones, audio, wearables, computers
|
||||
- **Manufacturer lookup** via OUI database
|
||||
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
|
||||
- **Proximity radar** visualization
|
||||
- **Device type breakdown** chart
|
||||
|
||||
## TSCM Counter-Surveillance Mode
|
||||
|
||||
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||
|
||||
### Wireless Sweep Features
|
||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
|
||||
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||
|
||||
### MAC-Randomization Resistant Detection
|
||||
- **Device fingerprinting** based on advertisement payloads, not MAC addresses
|
||||
- **Behavioral clustering** - groups observations into probable physical devices
|
||||
- **Session tracking** - monitors device presence windows
|
||||
- **Timing pattern analysis** - detects characteristic advertising intervals
|
||||
- **RSSI trajectory correlation** - identifies co-located devices
|
||||
|
||||
### Risk Assessment
|
||||
- **Three-tier scoring model**:
|
||||
- Informational (0-2): Known or expected devices
|
||||
- Needs Review (3-5): Unusual devices requiring assessment
|
||||
- High Interest (6+): Multiple indicators warrant investigation
|
||||
- **Risk indicators**: Stable RSSI, audio-capable, ESP32 chipsets, hidden identity, MAC rotation
|
||||
- **Audit trail** - full evidence chain for each link/flag
|
||||
- **Client-safe disclaimers** - findings are indicators, not confirmed surveillance
|
||||
|
||||
### Limitations (Documented)
|
||||
- Cannot detect non-transmitting devices
|
||||
- False positives/negatives expected
|
||||
- Results require professional verification
|
||||
- No cryptographic de-randomization
|
||||
- Passive screening only (no active probing by default)
|
||||
|
||||
## Meshtastic Mesh Networks
|
||||
|
||||
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||
|
||||
### Device Support
|
||||
- **Heltec** - LoRa32 series
|
||||
- **T-Beam** - TTGO T-Beam with GPS
|
||||
- **RAK** - WisBlock series
|
||||
- Any Meshtastic-compatible device via USB/Serial
|
||||
|
||||
### Features
|
||||
- **Real-time messaging** - Stream messages as they arrive
|
||||
- **Channel configuration** - Set encryption keys and channel names
|
||||
- **Node information** - View connected nodes with signal metrics
|
||||
- **Message history** - Up to 500 messages retained
|
||||
- **Signal quality** - RSSI and SNR for each message
|
||||
- **Hop tracking** - See message hop count
|
||||
|
||||
### Requirements
|
||||
- Physical Meshtastic device connected via USB
|
||||
- Meshtastic Python SDK (`pip install meshtastic`)
|
||||
|
||||
## Ubertooth One BLE Scanning
|
||||
|
||||
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
|
||||
|
||||
### Capabilities
|
||||
- **40-channel scanning** - Capture BLE advertisements across all channels
|
||||
- **Raw payload access** - Full advertising data for analysis
|
||||
- **Passive sniffing** - No active scanning required
|
||||
- **MAC address extraction** - Public and random address types
|
||||
- **RSSI measurement** - Signal strength for proximity estimation
|
||||
|
||||
### Integration
|
||||
- Works alongside standard BlueZ/DBus Bluetooth scanning
|
||||
- Automatically detected when ubertooth-btle is available
|
||||
- Falls back to standard adapter if Ubertooth not present
|
||||
|
||||
### Requirements
|
||||
- Ubertooth One hardware
|
||||
- ubertooth-btle command-line tool installed
|
||||
- libubertooth library
|
||||
|
||||
## Remote Agents (Distributed SIGINT)
|
||||
|
||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||
|
||||
### Architecture
|
||||
- **Hub-and-spoke model** - Central controller with multiple remote agents
|
||||
- **Push and Pull modes** - Agents can push data automatically or respond to on-demand requests
|
||||
- **API key authentication** - Secure communication between agents and controller
|
||||
|
||||
### Agent Features
|
||||
- **Standalone deployment** - Run on Raspberry Pi, mini PCs, or any Linux device with SDR
|
||||
- **All modes supported** - Pager, sensor, ADS-B, AIS, WiFi, Bluetooth, and more
|
||||
- **GPS integration** - Automatic location tagging from USB GPS receivers
|
||||
- **Multi-SDR support** - Run multiple modes simultaneously on agents with multiple SDRs
|
||||
- **Capability discovery** - Controller auto-detects available modes and devices
|
||||
|
||||
### Controller Features
|
||||
- **Agent management UI** - Register, test, and remove agents from `/controller/manage`
|
||||
- **Real-time status** - Health monitoring with online/offline indicators
|
||||
- **Unified data stream** - Aggregate data from all agents via SSE
|
||||
- **Dashboard integration** - Agent selector in ADS-B, AIS, and main dashboards
|
||||
- **Device conflict detection** - Smart warnings when SDR is in use
|
||||
|
||||
### Use Cases
|
||||
- **Wide-area monitoring** - Cover larger geographic areas with distributed sensors
|
||||
- **Remote installations** - Deploy sensors in locations without direct access
|
||||
- **Redundancy** - Multiple nodes for reliable coverage
|
||||
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||
|
||||
## User Interface
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
@@ -103,6 +258,42 @@ Complete feature list for all modules.
|
||||
| ? | Open help (when not typing) |
|
||||
| Escape | Close help/modals |
|
||||
|
||||
## Offline Mode
|
||||
|
||||
Run iNTERCEPT without internet connectivity by using bundled local assets.
|
||||
|
||||
### Bundled Assets
|
||||
- **Leaflet 1.9.4** - Map library with marker images
|
||||
- **Chart.js 4.4.1** - Signal strength graphs
|
||||
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
|
||||
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
|
||||
|
||||
### Settings Modal
|
||||
Access via the gear icon in the navigation bar:
|
||||
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
|
||||
- **Display Tab** - Theme and animation preferences
|
||||
- **About Tab** - Version info and links
|
||||
|
||||
### Map Tile Providers
|
||||
Choose from multiple tile sources for maps:
|
||||
- **OpenStreetMap** - Default, general purpose
|
||||
- **CartoDB Dark** - Dark themed, matches UI
|
||||
- **CartoDB Positron** - Light themed
|
||||
- **ESRI World Imagery** - Satellite imagery
|
||||
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
|
||||
|
||||
### Local Asset Status
|
||||
The settings modal shows availability status for each bundled asset:
|
||||
- Green "Available" badge when asset is present
|
||||
- Red "Missing" badge when asset is not found
|
||||
- Click "Check Assets" to refresh status
|
||||
|
||||
### Use Cases
|
||||
- **Air-gapped environments** - Run on isolated networks
|
||||
- **Field deployments** - Operate without reliable internet
|
||||
- **Local tile servers** - Use pre-cached map tiles for specific regions
|
||||
- **Reduced latency** - Faster loading with local assets
|
||||
|
||||
## General
|
||||
|
||||
- **Web-based interface** - no desktop app needed
|
||||
|
||||
@@ -139,14 +139,10 @@ pip install -r requirements.txt
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# Standard
|
||||
sudo python3 intercept.py
|
||||
|
||||
# With virtual environment
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Custom port
|
||||
INTERCEPT_PORT=8080 sudo python3 intercept.py
|
||||
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
Open **http://localhost:5050** in your browser.
|
||||
@@ -183,6 +179,7 @@ Open **http://localhost:5050** in your browser.
|
||||
|---------|---------|
|
||||
| `flask` | Web server |
|
||||
| `skyfield` | Satellite tracking |
|
||||
| `bleak` | BLE scanning with manufacturer data (TSCM) |
|
||||
|
||||
---
|
||||
|
||||
@@ -203,9 +200,57 @@ https://github.com/flightaware/dump1090
|
||||
|
||||
---
|
||||
|
||||
## TSCM Mode Requirements
|
||||
|
||||
TSCM (Technical Surveillance Countermeasures) mode requires specific hardware for full functionality:
|
||||
|
||||
### BLE Scanning (Tracker Detection)
|
||||
- Any Bluetooth adapter supported by your OS
|
||||
- `bleak` Python library for manufacturer data detection
|
||||
- Detects: AirTags, Tile, SmartTags, ESP32/ESP8266 devices
|
||||
|
||||
```bash
|
||||
# Install bleak
|
||||
pip install bleak>=0.21.0
|
||||
|
||||
# Or via apt (Debian/Ubuntu)
|
||||
sudo apt install python3-bleak
|
||||
```
|
||||
|
||||
### RF Spectrum Analysis
|
||||
- **RTL-SDR dongle** (required for RF sweeps)
|
||||
- `rtl_power` command from `rtl-sdr` package
|
||||
|
||||
Frequency bands scanned:
|
||||
| Band | Frequency | Purpose |
|
||||
|------|-----------|---------|
|
||||
| FM Broadcast | 88-108 MHz | FM bugs |
|
||||
| 315 MHz ISM | 315 MHz | US wireless devices |
|
||||
| 433 MHz ISM | 433-434 MHz | EU wireless devices |
|
||||
| 868 MHz ISM | 868-869 MHz | EU IoT devices |
|
||||
| 915 MHz ISM | 902-928 MHz | US IoT devices |
|
||||
| 1.2 GHz | 1200-1300 MHz | Video transmitters |
|
||||
| 2.4 GHz ISM | 2400-2500 MHz | WiFi/BT/Video |
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
sudo apt install rtl-sdr
|
||||
|
||||
# macOS
|
||||
brew install librtlsdr
|
||||
```
|
||||
|
||||
### WiFi Scanning
|
||||
- Standard WiFi adapter (managed mode for basic scanning)
|
||||
- Monitor mode capable adapter for advanced features
|
||||
- `aircrack-ng` suite for monitor mode management
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
|
||||
- **Bluetooth on macOS**: Uses bleak library (CoreBluetooth backend), bluez tools not needed
|
||||
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
|
||||
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
|
||||
- **TSCM on macOS**: BLE and WiFi scanning work; RF spectrum requires RTL-SDR
|
||||
|
||||
|
||||
@@ -336,9 +336,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo python3 intercept.py
|
||||
# Or with venv:
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
|
||||
@@ -79,6 +79,38 @@ The system highlights aircraft transmitting emergency squawks:
|
||||
- **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)
|
||||
|
||||
## Satellite Mode
|
||||
|
||||
1. **Set Location** - Choose location source:
|
||||
@@ -98,6 +130,58 @@ The system highlights aircraft transmitting emergency squawks:
|
||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||
4. Select satellites to add
|
||||
|
||||
## Remote Agents (Distributed SIGINT)
|
||||
|
||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||
|
||||
### Setting Up an Agent
|
||||
|
||||
1. **Install INTERCEPT** on the remote machine
|
||||
2. **Create config file** (`intercept_agent.cfg`):
|
||||
```ini
|
||||
[agent]
|
||||
name = sensor-node-1
|
||||
port = 8020
|
||||
|
||||
[controller]
|
||||
url = http://192.168.1.100:5050
|
||||
api_key = your-secret-key
|
||||
push_enabled = true
|
||||
|
||||
[modes]
|
||||
pager = true
|
||||
sensor = true
|
||||
adsb = true
|
||||
```
|
||||
3. **Start the agent**:
|
||||
```bash
|
||||
python intercept_agent.py --config intercept_agent.cfg
|
||||
```
|
||||
|
||||
### Registering Agents in the Controller
|
||||
|
||||
1. Navigate to `/controller/manage` in the main INTERCEPT instance
|
||||
2. Enter agent details:
|
||||
- **Name**: Must match config file (e.g., `sensor-node-1`)
|
||||
- **Base URL**: Agent address (e.g., `http://192.168.1.50:8020`)
|
||||
- **API Key**: Must match config file
|
||||
3. Click "Register Agent"
|
||||
4. Use "Test" to verify connectivity
|
||||
|
||||
### Using Remote Agents
|
||||
|
||||
Once registered, agents appear in mode dropdowns:
|
||||
|
||||
1. **Select agent** from the dropdown in supported modes
|
||||
2. **Start mode** - Commands are proxied to the remote agent
|
||||
3. **View data** - Data streams back to your browser via SSE
|
||||
|
||||
### Multi-Agent Streaming
|
||||
|
||||
Enable "Show All Agents" to aggregate data from all registered agents simultaneously.
|
||||
|
||||
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
INTERCEPT can be configured via environment variables:
|
||||
@@ -110,7 +194,7 @@ INTERCEPT can be configured via environment variables:
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||
|
||||
Example: `INTERCEPT_PORT=8080 sudo python3 intercept.py`
|
||||
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
||||
|
||||
## Command-line Options
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
title: iNTERCEPT
|
||||
description: Signal Intelligence Platform - A web-based interface for software-defined radio tools
|
||||
url: https://smittix.github.io
|
||||
baseurl: /intercept
|
||||
|
||||
# Build settings
|
||||
include:
|
||||
- _headers
|
||||
|
||||
# Exclude files from build
|
||||
exclude:
|
||||
- README.md
|
||||
- SECURITY.md
|
||||
- TROUBLESHOOTING.md
|
||||
- USAGE.md
|
||||
- FEATURES.md
|
||||
- HARDWARE.md
|
||||
- DISTRIBUTED_AGENTS.md
|
||||
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 585 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 698 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 811 KiB |
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iNTERCEPT - Signal Intelligence Platform</title>
|
||||
<meta name="description" content="A web-based interface for software-defined radio tools. Pager decoding, ADS-B tracking, WiFi scanning, and more.">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<a href="#" class="nav-logo">iNTERCEPT</a>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#screenshots">Screenshots</a>
|
||||
<a href="#installation">Install</a>
|
||||
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||
<a href="https://github.com/smittix/intercept" class="nav-btn" target="_blank">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-badge">Open Source SIGINT Platform</div>
|
||||
<h1>iNTERCEPT</h1>
|
||||
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="#installation" class="btn btn-primary">Get Started</a>
|
||||
<a href="https://github.com/smittix/intercept" class="btn btn-secondary" target="_blank">View on GitHub</a>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">12+</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">200+</span>
|
||||
<span class="stat-label">Protocols</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">$25</span>
|
||||
<span class="stat-label">Min Hardware</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-image">
|
||||
<img src="images/dashboard.png" alt="iNTERCEPT Dashboard">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="features" class="features">
|
||||
<div class="container">
|
||||
<h2>Capabilities</h2>
|
||||
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📟</div>
|
||||
<h3>Pager Decoding</h3>
|
||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">✈️</div>
|
||||
<h3>Aircraft Tracking</h3>
|
||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<h3>433MHz Sensors</h3>
|
||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📻</div>
|
||||
<h3>Listening Post</h3>
|
||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<h3>Satellite Tracking</h3>
|
||||
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📶</div>
|
||||
<h3>WiFi Scanning</h3>
|
||||
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔵</div>
|
||||
<h3>Bluetooth Scanning</h3>
|
||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Meter Reading</h3>
|
||||
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚢</div>
|
||||
<h3>Vessel Tracking</h3>
|
||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔢</div>
|
||||
<h3>Spy Stations</h3>
|
||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>Remote Agents</h3>
|
||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📴</div>
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<h3>Meshtastic</h3>
|
||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="screenshots" class="screenshots">
|
||||
<div class="container">
|
||||
<h2>See It In Action</h2>
|
||||
<p class="section-subtitle">A clean, modern interface for complex RF operations</p>
|
||||
|
||||
<div class="screenshot-gallery">
|
||||
<div class="screenshot-item">
|
||||
<img src="images/dashboard.png" alt="Main Dashboard">
|
||||
<span class="screenshot-label">Dashboard</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/tscm.png" alt="TSCM Counter-Surveillance">
|
||||
<span class="screenshot-label">TSCM Counter-Surveillance</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/bluetooth.png" alt="Bluetooth Scanner">
|
||||
<span class="screenshot-label">Bluetooth Scanner</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/wifi.png" alt="WiFi Scanner">
|
||||
<span class="screenshot-label">WiFi Scanner</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/scanner.png" alt="Listening Post">
|
||||
<span class="screenshot-label">Listening Post</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/sensors.png" alt="433MHz Sensor Monitor">
|
||||
<span class="screenshot-label">433MHz Sensors</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/tscm-detail.png" alt="Device Detail Dialog">
|
||||
<span class="screenshot-label">Device Analysis</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/remote-agents.png" alt="Remote Agents Management">
|
||||
<span class="screenshot-label">Remote Agents</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="installation" class="installation">
|
||||
<div class="container">
|
||||
<h2>Quick Start</h2>
|
||||
<p class="section-subtitle">Get up and running in minutes</p>
|
||||
|
||||
<div class="platform-note">
|
||||
<p><strong>Supported Platforms:</strong> Officially tested on Debian and Ubuntu. Partial support for macOS. Other distributions have not been fully tested.</p>
|
||||
</div>
|
||||
|
||||
<div class="install-options">
|
||||
<div class="install-card">
|
||||
<h3>Standard Installation</h3>
|
||||
<div class="code-block">
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
||||
</div>
|
||||
|
||||
<div class="install-card">
|
||||
<h3>Docker</h3>
|
||||
<div class="code-block">
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker compose up -d</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-install">
|
||||
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
||||
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hardware">
|
||||
<div class="container">
|
||||
<h2>Hardware</h2>
|
||||
<p class="section-subtitle">Minimal hardware, maximum capability</p>
|
||||
|
||||
<div class="hardware-grid">
|
||||
<div class="hardware-card required">
|
||||
<div class="hardware-tag">Required</div>
|
||||
<h3>RTL-SDR</h3>
|
||||
<p>Core SDR functionality for all radio features</p>
|
||||
<span class="price">~$25-35</span>
|
||||
</div>
|
||||
<div class="hardware-card optional">
|
||||
<div class="hardware-tag">Optional</div>
|
||||
<h3>WiFi Adapter</h3>
|
||||
<p>Monitor mode support for WiFi scanning</p>
|
||||
<span class="price">~$20-40</span>
|
||||
</div>
|
||||
<div class="hardware-card optional">
|
||||
<div class="hardware-tag">Optional</div>
|
||||
<h3>GPS Receiver</h3>
|
||||
<p>Real-time location for mapping features</p>
|
||||
<span class="price">~$10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hardware-note">iNTERCEPT also supports HackRF, LimeSDR, Airspy, and SDRplay via SoapySDR</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<div class="container">
|
||||
<h2>Ready to start intercepting?</h2>
|
||||
<p>Join the community and start exploring the RF spectrum</p>
|
||||
<div class="cta-buttons">
|
||||
<a href="https://github.com/smittix/intercept" class="btn btn-primary" target="_blank">Get iNTERCEPT</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE" class="btn btn-secondary" target="_blank">Join Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-brand">
|
||||
<span class="footer-logo">iNTERCEPT</span>
|
||||
<p>Signal Intelligence Platform</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Lightbox Modal -->
|
||||
<div id="lightbox" class="lightbox">
|
||||
<span class="lightbox-close">×</span>
|
||||
<img class="lightbox-img" id="lightbox-img" src="" alt="">
|
||||
<div class="lightbox-caption" id="lightbox-caption"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Lightbox functionality
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
const lightboxImg = document.getElementById('lightbox-img');
|
||||
const lightboxCaption = document.getElementById('lightbox-caption');
|
||||
const closeBtn = document.querySelector('.lightbox-close');
|
||||
|
||||
document.querySelectorAll('.screenshot-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const img = item.querySelector('img');
|
||||
const label = item.querySelector('.screenshot-label');
|
||||
lightbox.classList.add('active');
|
||||
lightboxImg.src = img.src;
|
||||
lightboxCaption.textContent = label.textContent;
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
});
|
||||
|
||||
function closeLightbox() {
|
||||
lightbox.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
closeBtn.addEventListener('click', closeLightbox);
|
||||
lightbox.addEventListener('click', (e) => {
|
||||
if (e.target === lightbox) closeLightbox();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,694 @@
|
||||
/* INTERCEPT GitHub Pages - Dark Theme */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-card: #1a1a24;
|
||||
--bg-card-hover: #22222e;
|
||||
--text-primary: #f0f0f5;
|
||||
--text-secondary: #8888a0;
|
||||
--text-muted: #5c5c70;
|
||||
--accent: #00d4aa;
|
||||
--accent-hover: #00f0c0;
|
||||
--accent-glow: rgba(0, 212, 170, 0.2);
|
||||
--border: #2a2a38;
|
||||
--code-bg: #0d0d14;
|
||||
--gradient-start: #00d4aa;
|
||||
--gradient-end: #0088ff;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(10, 10, 15, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary) !important;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 60px;
|
||||
padding: 120px 24px 80px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--accent);
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 4.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 8px;
|
||||
margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
max-width: 500px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-image img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
section {
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
/* Features */
|
||||
.features {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Screenshots */
|
||||
.screenshot-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.screenshot-item:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.screenshot-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.screenshot-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Lightbox */
|
||||
.lightbox {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.lightbox.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 30px;
|
||||
font-size: 40px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lightbox-img {
|
||||
max-width: 90%;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.lightbox-caption {
|
||||
margin-top: 20px;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Installation */
|
||||
.installation {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.install-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.install-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.install-card h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--code-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.install-note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.platform-note {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.platform-note p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.platform-note strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.post-install {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.post-install p {
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-install code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: var(--code-bg);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Hardware */
|
||||
.hardware-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.hardware-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hardware-tag {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hardware-card.required .hardware-tag {
|
||||
background: var(--accent-glow);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.hardware-card.optional .hardware-tag {
|
||||
background: rgba(136, 136, 160, 0.1);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hardware-card h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hardware-card p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hardware-card .price {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hardware-note {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* CTA */
|
||||
.cta {
|
||||
background: linear-gradient(135deg, rgba(0, 212, 170, 0.1), rgba(0, 136, 255, 0.1));
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cta p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: var(--bg-secondary);
|
||||
padding: 60px 0 32px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.footer-brand p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.footer-bottom a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
order: -1;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.install-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hardware-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# =============================================================================
|
||||
# INTERCEPT AGENT CONFIGURATION
|
||||
# =============================================================================
|
||||
# This file configures the Intercept remote agent.
|
||||
# Copy this file and customize for your deployment.
|
||||
|
||||
[agent]
|
||||
# Agent name (used to identify this node in the controller)
|
||||
# Default: system hostname
|
||||
name = sensor-node-1
|
||||
|
||||
# HTTP server port
|
||||
# Default: 8020
|
||||
port = 8020
|
||||
|
||||
# Comma-separated list of allowed client IPs (empty = allow all)
|
||||
# Example: 192.168.1.100, 192.168.1.101, 10.0.0.0/8
|
||||
allowed_ips =
|
||||
|
||||
# Enable CORS headers for browser-based clients
|
||||
# Default: false
|
||||
allow_cors = false
|
||||
|
||||
|
||||
[controller]
|
||||
# Controller URL for push mode
|
||||
# Example: http://192.168.1.100:5050
|
||||
url =
|
||||
|
||||
# API key for controller authentication (shared secret)
|
||||
api_key =
|
||||
|
||||
# Enable automatic push of scan data to controller
|
||||
# Default: false
|
||||
push_enabled = false
|
||||
|
||||
# Push interval in seconds (minimum time between pushes)
|
||||
# Default: 5
|
||||
push_interval = 5
|
||||
|
||||
|
||||
[modes]
|
||||
# Enable/disable specific modes on this agent
|
||||
# Set to false to disable a mode even if tools are available
|
||||
# Default: all true
|
||||
|
||||
pager = true
|
||||
sensor = true
|
||||
adsb = true
|
||||
ais = true
|
||||
acars = true
|
||||
aprs = true
|
||||
wifi = true
|
||||
bluetooth = true
|
||||
dsc = true
|
||||
rtlamr = true
|
||||
tscm = true
|
||||
satellite = true
|
||||
listening_post = true
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.0.0"
|
||||
version = "2.10.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -29,6 +29,11 @@ dependencies = [
|
||||
"flask>=2.0.0",
|
||||
"skyfield>=1.45",
|
||||
"pyserial>=3.5",
|
||||
"Werkzeug>=3.1.5",
|
||||
"flask-limiter>=2.5.4",
|
||||
"bleak>=0.21.0",
|
||||
"flask-sock",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
# Core dependencies
|
||||
flask>=2.0.0
|
||||
flask-limiter>=2.5.4
|
||||
requests>=2.28.0
|
||||
Werkzeug>=3.1.5
|
||||
|
||||
# ADS-B history (optional - only needed for Postgres persistence)
|
||||
psycopg2-binary>=2.9.9
|
||||
|
||||
# BLE scanning with manufacturer data detection (optional - for TSCM)
|
||||
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)
|
||||
scipy>=1.10.0
|
||||
numpy>=1.24.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
|
||||
|
||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
flask-sock
|
||||
flask-sock
|
||||
|
||||
@@ -4,22 +4,51 @@ def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .adsb import adsb_bp
|
||||
from .ais import ais_bp
|
||||
from .dsc import dsc_bp
|
||||
from .acars import acars_bp
|
||||
from .aprs import aprs_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
from .spy_stations import spy_stations_bp
|
||||
from .controller import controller_bp
|
||||
from .offline import offline_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
app.register_blueprint(rtlamr_bp)
|
||||
app.register_blueprint(wifi_bp)
|
||||
app.register_blueprint(wifi_v2_bp) # New unified WiFi API
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(ais_bp)
|
||||
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||
app.register_blueprint(acars_bp)
|
||||
app.register_blueprint(aprs_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
app.register_blueprint(controller_bp) # Remote agent controller
|
||||
app.register_blueprint(offline_bp) # Offline mode settings
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""ACARS aircraft messaging routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
"""Find acarsdec binary."""
|
||||
return shutil.which('acarsdec')
|
||||
|
||||
|
||||
def get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
||||
"""Detect which JSON output flag acarsdec supports.
|
||||
|
||||
Different forks use different flags:
|
||||
- TLeconte v4.0+: uses -j for JSON stdout
|
||||
- TLeconte v3.x: uses -o 4 for JSON stdout
|
||||
- f00b4r0 fork (DragonOS): uses --output json:file:- for JSON stdout
|
||||
"""
|
||||
try:
|
||||
# Get help/version by running acarsdec with no args (shows usage)
|
||||
result = subprocess.run(
|
||||
[acarsdec_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
import re
|
||||
|
||||
# Check for f00b4r0 fork signature: uses --output instead of -j/-o
|
||||
# f00b4r0's help shows "--output" for output configuration
|
||||
if '--output' in output or 'json:file:' in output.lower():
|
||||
logger.debug("Detected f00b4r0 acarsdec fork (--output syntax)")
|
||||
return '--output'
|
||||
|
||||
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
|
||||
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
|
||||
if version_match:
|
||||
major = int(version_match.group(1))
|
||||
# Version 4.0+ uses -j for JSON stdout
|
||||
if major >= 4:
|
||||
return '-j'
|
||||
# Version 3.x uses -o for output mode
|
||||
else:
|
||||
return '-o'
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not detect acarsdec version: {e}")
|
||||
|
||||
# Default to -j (TLeconte modern standard)
|
||||
return '-j'
|
||||
|
||||
|
||||
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||
"""Stream acarsdec JSON output to queue."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
|
||||
try:
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||
sentinel = '' if is_text_mode else b''
|
||||
for line in iter(process.stdout.readline, sentinel):
|
||||
if is_text_mode:
|
||||
line = line.strip()
|
||||
else:
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# acarsdec -o 4 outputs JSON, one message per line
|
||||
data = json.loads(line)
|
||||
|
||||
# Add our metadata
|
||||
data['type'] = 'acars'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Update stats
|
||||
acars_message_count += 1
|
||||
acars_last_message_time = time.time()
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | ACARS | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON - could be status message
|
||||
if line:
|
||||
logger.debug(f"acarsdec non-JSON: {line[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.acars_lock:
|
||||
app_module.acars_process = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
def check_acars_tools() -> Response:
|
||||
"""Check for ACARS decoding tools."""
|
||||
has_acarsdec = find_acarsdec() is not None
|
||||
|
||||
return jsonify({
|
||||
'acarsdec': has_acarsdec,
|
||||
'ready': has_acarsdec
|
||||
})
|
||||
|
||||
|
||||
@acars_bp.route('/status')
|
||||
def acars_status() -> Response:
|
||||
"""Get ACARS decoder status."""
|
||||
running = False
|
||||
if app_module.acars_process:
|
||||
running = app_module.acars_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'message_count': acars_message_count,
|
||||
'last_message_time': acars_last_message_time,
|
||||
'queue_size': app_module.acars_queue.qsize()
|
||||
})
|
||||
|
||||
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for acarsdec
|
||||
acarsdec_path = find_acarsdec()
|
||||
if not acarsdec_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
if isinstance(frequencies, str):
|
||||
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||
|
||||
# Clear queue
|
||||
while not app_module.acars_queue.empty():
|
||||
try:
|
||||
app_module.acars_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Reset stats
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Build acarsdec command
|
||||
# Different forks have different syntax:
|
||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||
# Note: gain/ppm must come BEFORE -r
|
||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||
cmd = [acarsdec_path]
|
||||
if json_flag == '--output':
|
||||
# f00b4r0 fork: --output json:file (no path = stdout)
|
||||
cmd.extend(['--output', 'json:file'])
|
||||
elif json_flag == '-j':
|
||||
cmd.append('-j') # JSON output (TLeconte v4+)
|
||||
else:
|
||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||
|
||||
# Add gain if not auto (must be before -r)
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if specified (must be before -r)
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Add device and frequencies
|
||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
||||
if json_flag == '--output':
|
||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||
cmd.extend(['-m', '256'])
|
||||
cmd.extend(['--rtlsdr', str(device)])
|
||||
else:
|
||||
cmd.extend(['-r', str(device)])
|
||||
cmd.extend(frequencies)
|
||||
|
||||
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
is_text_mode = False
|
||||
|
||||
# On macOS, use pty to avoid stdout buffering issues
|
||||
if platform.system() == 'Darwin':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# Wrap master_fd as a text file for line-buffered reading
|
||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||
is_text_mode = True
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait briefly to check if process started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process died
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = f'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
app_module.acars_process = process
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_acars_output,
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequencies': frequencies,
|
||||
'device': device,
|
||||
'gain': gain
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder not running'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
app_module.acars_process.terminate()
|
||||
app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.acars_process.kill()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping ACARS: {e}")
|
||||
|
||||
app_module.acars_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@acars_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get default ACARS frequencies."""
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
})
|
||||
@@ -2,27 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.validation import (
|
||||
validate_device_index, validate_gain,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
from flask import make_response
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
import app as app_module
|
||||
from config import (
|
||||
ADSB_DB_HOST,
|
||||
ADSB_DB_NAME,
|
||||
ADSB_DB_PASSWORD,
|
||||
ADSB_DB_PORT,
|
||||
ADSB_DB_USER,
|
||||
ADSB_HISTORY_ENABLED,
|
||||
)
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.validation import (
|
||||
validate_device_index, validate_gain,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
from utils.constants import (
|
||||
ADSB_SBS_PORT,
|
||||
ADSB_TERMINATE_TIMEOUT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
@@ -33,9 +45,10 @@ from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SOCKET_CONNECT_TIMEOUT,
|
||||
ADSB_UPDATE_INTERVAL,
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
from utils.adsb_history import adsb_history_writer, adsb_snapshot_writer, _ensure_adsb_schema
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
@@ -46,6 +59,8 @@ adsb_messages_received = 0
|
||||
adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
adsb_active_device = None # Track which device index is being used
|
||||
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
@@ -54,7 +69,7 @@ _looked_up_icaos: set[str] = set()
|
||||
aircraft_db.load_database()
|
||||
|
||||
# Common installation paths for dump1090 (when not in PATH)
|
||||
DUMP1090_PATHS = [
|
||||
DUMP1090_PATHS = [
|
||||
# Homebrew on Apple Silicon (M1/M2/M3)
|
||||
'/opt/homebrew/bin/dump1090',
|
||||
'/opt/homebrew/bin/dump1090-fa',
|
||||
@@ -67,8 +82,202 @@ DUMP1090_PATHS = [
|
||||
'/usr/bin/dump1090',
|
||||
'/usr/bin/dump1090-fa',
|
||||
'/usr/bin/dump1090-mutability',
|
||||
]
|
||||
|
||||
]
|
||||
|
||||
|
||||
def _get_part(parts: list[str], index: int) -> str | None:
|
||||
if len(parts) <= index:
|
||||
return None
|
||||
value = parts[index].strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _parse_sbs_timestamp(date_str: str | None, time_str: str | None) -> datetime | None:
|
||||
if not date_str or not time_str:
|
||||
return None
|
||||
combined = f"{date_str} {time_str}"
|
||||
for fmt in ("%Y/%m/%d %H:%M:%S.%f", "%Y/%m/%d %H:%M:%S"):
|
||||
try:
|
||||
parsed = datetime.strptime(combined, fmt)
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_history_record(
|
||||
parts: list[str],
|
||||
msg_type: str,
|
||||
icao: str,
|
||||
msg_time: datetime | None,
|
||||
logged_time: datetime | None,
|
||||
service_addr: str,
|
||||
raw_line: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
'received_at': datetime.now(timezone.utc),
|
||||
'msg_time': msg_time,
|
||||
'logged_time': logged_time,
|
||||
'icao': icao,
|
||||
'msg_type': _parse_int(msg_type),
|
||||
'callsign': _get_part(parts, 10),
|
||||
'altitude': _parse_int(_get_part(parts, 11)),
|
||||
'speed': _parse_int(_get_part(parts, 12)),
|
||||
'heading': _parse_int(_get_part(parts, 13)),
|
||||
'vertical_rate': _parse_int(_get_part(parts, 16)),
|
||||
'lat': _parse_float(_get_part(parts, 14)),
|
||||
'lon': _parse_float(_get_part(parts, 15)),
|
||||
'squawk': _get_part(parts, 17),
|
||||
'session_id': _get_part(parts, 2),
|
||||
'aircraft_id': _get_part(parts, 3),
|
||||
'flight_id': _get_part(parts, 5),
|
||||
'raw_line': raw_line,
|
||||
'source_host': service_addr,
|
||||
}
|
||||
|
||||
|
||||
_history_schema_checked = False
|
||||
|
||||
|
||||
def _get_history_connection():
|
||||
return psycopg2.connect(
|
||||
host=ADSB_DB_HOST,
|
||||
port=ADSB_DB_PORT,
|
||||
dbname=ADSB_DB_NAME,
|
||||
user=ADSB_DB_USER,
|
||||
password=ADSB_DB_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_history_schema() -> None:
|
||||
global _history_schema_checked
|
||||
if _history_schema_checked:
|
||||
return
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
_ensure_adsb_schema(conn)
|
||||
_history_schema_checked = True
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B schema check failed: %s", exc)
|
||||
|
||||
|
||||
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value) if value is not None else default
|
||||
except (ValueError, TypeError):
|
||||
parsed = default
|
||||
if min_value is not None:
|
||||
parsed = max(min_value, parsed)
|
||||
if max_value is not None:
|
||||
parsed = min(max_value, parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _get_active_session() -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM adsb_sessions
|
||||
WHERE ended_at IS NULL
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session lookup failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _record_session_start(
|
||||
*,
|
||||
device_index: int | None,
|
||||
sdr_type: str | None,
|
||||
remote_host: str | None,
|
||||
remote_port: int | None,
|
||||
start_source: str | None,
|
||||
started_by: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO adsb_sessions (
|
||||
device_index,
|
||||
sdr_type,
|
||||
remote_host,
|
||||
remote_port,
|
||||
start_source,
|
||||
started_by
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
device_index,
|
||||
sdr_type,
|
||||
remote_host,
|
||||
remote_port,
|
||||
start_source,
|
||||
started_by,
|
||||
),
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session start record failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE adsb_sessions
|
||||
SET ended_at = NOW(),
|
||||
stop_source = COALESCE(%s, stop_source),
|
||||
stopped_by = COALESCE(%s, stopped_by)
|
||||
WHERE ended_at IS NULL
|
||||
RETURNING *
|
||||
""",
|
||||
(stop_source, stopped_by),
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session stop record failed: %s", exc)
|
||||
return None
|
||||
|
||||
def find_dump1090():
|
||||
"""Find dump1090 binary, checking PATH and common locations."""
|
||||
@@ -98,16 +307,20 @@ def check_dump1090_service():
|
||||
return None
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""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
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""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
|
||||
|
||||
adsb_history_writer.start()
|
||||
adsb_snapshot_writer.start()
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
|
||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
_sbs_error_logged = False
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
@@ -115,6 +328,7 @@ def parse_sbs_stream(service_addr):
|
||||
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||
sock.connect((host, port))
|
||||
adsb_connected = True
|
||||
_sbs_error_logged = False # Reset so we log next error
|
||||
logger.info("Connected to SBS stream")
|
||||
|
||||
buffer = ""
|
||||
@@ -143,18 +357,31 @@ def parse_sbs_stream(service_addr):
|
||||
if adsb_lines_received <= 3:
|
||||
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
icao = parts[4].upper()
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
icao = parts[4].upper()
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
msg_time = _parse_sbs_timestamp(_get_part(parts, 6), _get_part(parts, 7))
|
||||
logged_time = _parse_sbs_timestamp(_get_part(parts, 8), _get_part(parts, 9))
|
||||
history_record = _build_history_record(
|
||||
parts=parts,
|
||||
msg_type=msg_type,
|
||||
icao=icao,
|
||||
msg_time=msg_time,
|
||||
logged_time=logged_time,
|
||||
service_addr=service_addr,
|
||||
raw_line=line,
|
||||
)
|
||||
adsb_history_writer.enqueue(history_record)
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
|
||||
# Look up aircraft type from database (once per ICAO)
|
||||
if icao not in _looked_up_icaos:
|
||||
@@ -225,12 +452,30 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
app_module.adsb_queue.put({
|
||||
'type': 'aircraft',
|
||||
**app_module.adsb_aircraft[update_icao]
|
||||
})
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
snapshot = app_module.adsb_aircraft[update_icao]
|
||||
app_module.adsb_queue.put({
|
||||
'type': 'aircraft',
|
||||
**snapshot
|
||||
})
|
||||
adsb_snapshot_writer.enqueue({
|
||||
'captured_at': datetime.now(timezone.utc),
|
||||
'icao': update_icao,
|
||||
'callsign': snapshot.get('callsign'),
|
||||
'registration': snapshot.get('registration'),
|
||||
'type_code': snapshot.get('type_code'),
|
||||
'type_desc': snapshot.get('type_desc'),
|
||||
'altitude': snapshot.get('altitude'),
|
||||
'speed': snapshot.get('speed'),
|
||||
'heading': snapshot.get('heading'),
|
||||
'vertical_rate': snapshot.get('vertical_rate'),
|
||||
'lat': snapshot.get('lat'),
|
||||
'lon': snapshot.get('lon'),
|
||||
'squawk': snapshot.get('squawk'),
|
||||
'source_host': service_addr,
|
||||
'snapshot': snapshot,
|
||||
})
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -241,7 +486,9 @@ def parse_sbs_stream(service_addr):
|
||||
adsb_connected = False
|
||||
except OSError as e:
|
||||
adsb_connected = False
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
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
|
||||
@@ -276,17 +523,18 @@ def check_adsb_tools():
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
# Check if dump1090 process is still running
|
||||
dump1090_running = False
|
||||
if app_module.adsb_process:
|
||||
dump1090_running = app_module.adsb_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'active_device': adsb_active_device,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'messages_received': adsb_messages_received,
|
||||
'bytes_received': adsb_bytes_received,
|
||||
'lines_received': adsb_lines_received,
|
||||
@@ -296,25 +544,50 @@ def adsb_status():
|
||||
'queue_size': app_module.adsb_queue.qsize(),
|
||||
'dump1090_path': find_dump1090(),
|
||||
'dump1090_running': dump1090_running,
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/session')
|
||||
def adsb_session():
|
||||
"""Get ADS-B session status and uptime."""
|
||||
session = _get_active_session()
|
||||
uptime_seconds = None
|
||||
if session and session.get('started_at'):
|
||||
started_at = session['started_at']
|
||||
if isinstance(started_at, datetime):
|
||||
uptime_seconds = int((datetime.now(timezone.utc) - started_at).total_seconds())
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'active_device': adsb_active_device,
|
||||
'session': session,
|
||||
'uptime_seconds': uptime_seconds,
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
return jsonify({'status': 'already_running', 'message': 'ADS-B tracking already active'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
session = _get_active_session()
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'message': 'ADS-B tracking already active',
|
||||
'session': session
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
start_source = data.get('source')
|
||||
started_by = request.remote_addr
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
@@ -330,21 +603,45 @@ def start_adsb():
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
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.start()
|
||||
return jsonify({'status': 'started', 'message': f'Connected to remote dump1090 at {remote_addr}'})
|
||||
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.start()
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type='remote',
|
||||
remote_host=remote_sbs_host,
|
||||
remote_port=remote_sbs_port,
|
||||
start_source=start_source,
|
||||
started_by=started_by,
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': f'Connected to remote dump1090 at {remote_addr}',
|
||||
'session': session
|
||||
})
|
||||
|
||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||
existing_service = check_dump1090_service()
|
||||
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.start()
|
||||
return jsonify({'status': 'started', 'message': 'Connected to existing dump1090 service'})
|
||||
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.start()
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type='external',
|
||||
remote_host='localhost',
|
||||
remote_port=ADSB_SBS_PORT,
|
||||
start_source=start_source,
|
||||
started_by=started_by,
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'Connected to existing dump1090 service',
|
||||
'session': session
|
||||
})
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
@@ -364,17 +661,20 @@ def start_adsb():
|
||||
if not dump1090_path:
|
||||
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'})
|
||||
|
||||
# Kill any stale app-started process
|
||||
# Kill any stale app-started process (use process group to ensure full cleanup)
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
app_module.adsb_process.terminate()
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 15) # SIGTERM
|
||||
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
app_module.adsb_process.kill()
|
||||
except OSError:
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 9) # SIGKILL
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
logger.info("Killed stale ADS-B process")
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
@@ -392,11 +692,13 @@ def start_adsb():
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
cmd[0] = dump1090_path
|
||||
|
||||
try:
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
try:
|
||||
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
@@ -420,33 +722,60 @@ def start_adsb():
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
||||
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)
|
||||
thread.start()
|
||||
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type=sdr_type.value,
|
||||
remote_host=None,
|
||||
remote_port=None,
|
||||
start_source=start_source,
|
||||
started_by=started_by,
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'ADS-B tracking started',
|
||||
'device': device,
|
||||
'session': session
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process:
|
||||
app_module.adsb_process.terminate()
|
||||
try:
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
data = request.json or {}
|
||||
stop_source = data.get('source')
|
||||
stopped_by = request.remote_addr
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
# Kill the entire process group to ensure all child processes are terminated
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 15) # SIGTERM
|
||||
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.adsb_process.kill()
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
# Force kill if terminate didn't work
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 9) # SIGKILL
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
adsb_using_service = False
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
logger.info("ADS-B process stopped")
|
||||
adsb_using_service = False
|
||||
adsb_active_device = None
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
session = _record_session_stop(stop_source=stop_source, stopped_by=stopped_by)
|
||||
return jsonify({'status': 'stopped', 'session': session})
|
||||
|
||||
|
||||
@adsb_bp.route('/stream')
|
||||
@@ -472,10 +801,165 @@ def stream_adsb():
|
||||
return response
|
||||
|
||||
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
|
||||
|
||||
@adsb_bp.route('/history')
|
||||
def adsb_history():
|
||||
"""ADS-B history reporting dashboard."""
|
||||
resp = make_response(render_template('adsb_history.html', history_enabled=ADSB_HISTORY_ENABLED))
|
||||
resp.headers['Cache-Control'] = 'no-store'
|
||||
return resp
|
||||
|
||||
|
||||
@adsb_bp.route('/history/summary')
|
||||
def adsb_history_summary():
|
||||
"""Summary stats for ADS-B history window."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||
window = f'{since_minutes} minutes'
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM adsb_messages WHERE received_at >= NOW() - INTERVAL %s) AS message_count,
|
||||
(SELECT COUNT(*) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS snapshot_count,
|
||||
(SELECT COUNT(DISTINCT icao) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS aircraft_count,
|
||||
(SELECT MIN(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS first_seen,
|
||||
(SELECT MAX(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS last_seen
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, window, window, window, window))
|
||||
row = cur.fetchone() or {}
|
||||
return jsonify(row)
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history summary failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
@adsb_bp.route('/history/aircraft')
|
||||
def adsb_history_aircraft():
|
||||
"""List latest aircraft snapshots for a time window."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||
search = (request.args.get('search') or '').strip()
|
||||
window = f'{since_minutes} minutes'
|
||||
pattern = f'%{search}%'
|
||||
|
||||
sql = """
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT DISTINCT ON (icao)
|
||||
icao,
|
||||
callsign,
|
||||
registration,
|
||||
type_code,
|
||||
type_desc,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
vertical_rate,
|
||||
lat,
|
||||
lon,
|
||||
squawk,
|
||||
captured_at AS last_seen
|
||||
FROM adsb_snapshots
|
||||
WHERE captured_at >= NOW() - INTERVAL %s
|
||||
AND (%s = '' OR icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)
|
||||
ORDER BY icao, captured_at DESC
|
||||
) latest
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'aircraft': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history aircraft query failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
@adsb_bp.route('/history/timeline')
|
||||
def adsb_history_timeline():
|
||||
"""Timeline snapshots for a specific aircraft."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
icao = (request.args.get('icao') or '').strip().upper()
|
||||
if not icao:
|
||||
return jsonify({'error': 'icao is required'}), 400
|
||||
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
|
||||
window = f'{since_minutes} minutes'
|
||||
|
||||
sql = """
|
||||
SELECT captured_at, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||
FROM adsb_snapshots
|
||||
WHERE icao = %s
|
||||
AND captured_at >= NOW() - INTERVAL %s
|
||||
ORDER BY captured_at ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (icao, window, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history timeline query failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
@adsb_bp.route('/history/messages')
|
||||
def adsb_history_messages():
|
||||
"""Raw message history for a specific aircraft."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
icao = (request.args.get('icao') or '').strip().upper()
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 30, 1, 10080)
|
||||
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||
window = f'{since_minutes} minutes'
|
||||
|
||||
sql = """
|
||||
SELECT received_at, msg_type, callsign, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||
FROM adsb_messages
|
||||
WHERE received_at >= NOW() - INTERVAL %s
|
||||
AND (%s = '' OR icao = %s)
|
||||
ORDER BY received_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, icao, icao, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history message query failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
# ============================================
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""AIS vessel tracking routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sse import format_sse
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
AIS_TCP_PORT,
|
||||
AIS_TERMINATE_TIMEOUT,
|
||||
AIS_SOCKET_TIMEOUT,
|
||||
AIS_RECONNECT_DELAY,
|
||||
AIS_UPDATE_INTERVAL,
|
||||
SOCKET_BUFFER_SIZE,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SOCKET_CONNECT_TIMEOUT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.ais')
|
||||
|
||||
ais_bp = Blueprint('ais', __name__, url_prefix='/ais')
|
||||
|
||||
# Track AIS state
|
||||
ais_running = False
|
||||
ais_connected = False
|
||||
ais_messages_received = 0
|
||||
ais_last_message_time = None
|
||||
ais_active_device = None
|
||||
_ais_error_logged = True
|
||||
|
||||
# Common installation paths for AIS-catcher
|
||||
AIS_CATCHER_PATHS = [
|
||||
'/usr/local/bin/AIS-catcher',
|
||||
'/usr/bin/AIS-catcher',
|
||||
'/opt/homebrew/bin/AIS-catcher',
|
||||
'/opt/homebrew/bin/aiscatcher',
|
||||
]
|
||||
|
||||
|
||||
def find_ais_catcher():
|
||||
"""Find AIS-catcher binary, checking PATH and common locations."""
|
||||
# First try PATH
|
||||
for name in ['AIS-catcher', 'aiscatcher']:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
# Check common installation paths
|
||||
for path in AIS_CATCHER_PATHS:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def parse_ais_stream(port: int):
|
||||
"""Parse JSON data from AIS-catcher TCP server."""
|
||||
global ais_running, ais_connected, ais_messages_received, ais_last_message_time, _ais_error_logged
|
||||
|
||||
logger.info(f"AIS stream parser started, connecting to localhost:{port}")
|
||||
ais_connected = True
|
||||
ais_messages_received = 0
|
||||
_ais_error_logged = True
|
||||
|
||||
while ais_running:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||
sock.connect(('localhost', port))
|
||||
ais_connected = True
|
||||
_ais_error_logged = True
|
||||
logger.info("Connected to AIS-catcher TCP server")
|
||||
|
||||
buffer = ""
|
||||
last_update = time.time()
|
||||
pending_updates = set()
|
||||
|
||||
while ais_running:
|
||||
try:
|
||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
logger.warning("AIS connection closed (no data)")
|
||||
break
|
||||
buffer += data
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
vessel = process_ais_message(msg)
|
||||
if vessel:
|
||||
mmsi = vessel.get('mmsi')
|
||||
if mmsi:
|
||||
app_module.ais_vessels.set(mmsi, vessel)
|
||||
pending_updates.add(mmsi)
|
||||
ais_messages_received += 1
|
||||
ais_last_message_time = time.time()
|
||||
except json.JSONDecodeError:
|
||||
if ais_messages_received < 5:
|
||||
logger.debug(f"Invalid JSON: {line[:100]}")
|
||||
|
||||
# Batch updates
|
||||
now = time.time()
|
||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||
for mmsi in pending_updates:
|
||||
if mmsi in app_module.ais_vessels:
|
||||
try:
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**app_module.ais_vessels[mmsi]
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
ais_connected = False
|
||||
except OSError as e:
|
||||
ais_connected = False
|
||||
if not _ais_error_logged:
|
||||
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||
_ais_error_logged = True
|
||||
time.sleep(AIS_RECONNECT_DELAY)
|
||||
|
||||
ais_connected = False
|
||||
logger.info("AIS stream parser stopped")
|
||||
|
||||
|
||||
def process_ais_message(msg: dict) -> dict | None:
|
||||
"""Process AIS-catcher JSON message and extract vessel data."""
|
||||
# AIS-catcher outputs different message types
|
||||
# We're interested in position reports and static data
|
||||
|
||||
mmsi = msg.get('mmsi')
|
||||
if not mmsi:
|
||||
return None
|
||||
|
||||
mmsi = str(mmsi)
|
||||
|
||||
# Get existing vessel data or create new
|
||||
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
|
||||
|
||||
# Extract common fields
|
||||
# AIS-catcher JSON_FULL uses 'longitude'/'latitude', but some versions use 'lon'/'lat'
|
||||
lat_val = msg.get('latitude') or msg.get('lat')
|
||||
lon_val = msg.get('longitude') or msg.get('lon')
|
||||
if lat_val is not None and lon_val is not None:
|
||||
try:
|
||||
lat = float(lat_val)
|
||||
lon = float(lon_val)
|
||||
# Validate coordinates (AIS uses 181 for unavailable)
|
||||
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||||
vessel['lat'] = lat
|
||||
vessel['lon'] = lon
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Speed over ground (knots)
|
||||
if 'speed' in msg:
|
||||
try:
|
||||
speed = float(msg['speed'])
|
||||
if speed < 102.3: # 102.3 = not available
|
||||
vessel['speed'] = round(speed, 1)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Course over ground (degrees)
|
||||
if 'course' in msg:
|
||||
try:
|
||||
course = float(msg['course'])
|
||||
if course < 360: # 360 = not available
|
||||
vessel['course'] = round(course, 1)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# True heading (degrees)
|
||||
if 'heading' in msg:
|
||||
try:
|
||||
heading = int(msg['heading'])
|
||||
if heading < 511: # 511 = not available
|
||||
vessel['heading'] = heading
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Navigation status
|
||||
if 'status' in msg:
|
||||
vessel['nav_status'] = msg['status']
|
||||
if 'status_text' in msg:
|
||||
vessel['nav_status_text'] = msg['status_text']
|
||||
|
||||
# Vessel name (from Type 5 or Type 24 messages)
|
||||
if 'shipname' in msg:
|
||||
name = msg['shipname'].strip().strip('@')
|
||||
if name:
|
||||
vessel['name'] = name
|
||||
|
||||
# Callsign
|
||||
if 'callsign' in msg:
|
||||
callsign = msg['callsign'].strip().strip('@')
|
||||
if callsign:
|
||||
vessel['callsign'] = callsign
|
||||
|
||||
# Ship type
|
||||
if 'shiptype' in msg:
|
||||
vessel['ship_type'] = msg['shiptype']
|
||||
if 'shiptype_text' in msg:
|
||||
vessel['ship_type_text'] = msg['shiptype_text']
|
||||
|
||||
# Destination
|
||||
if 'destination' in msg:
|
||||
dest = msg['destination'].strip().strip('@')
|
||||
if dest:
|
||||
vessel['destination'] = dest
|
||||
|
||||
# ETA
|
||||
if 'eta' in msg:
|
||||
vessel['eta'] = msg['eta']
|
||||
|
||||
# Dimensions
|
||||
if 'to_bow' in msg and 'to_stern' in msg:
|
||||
try:
|
||||
length = int(msg['to_bow']) + int(msg['to_stern'])
|
||||
if length > 0:
|
||||
vessel['length'] = length
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if 'to_port' in msg and 'to_starboard' in msg:
|
||||
try:
|
||||
width = int(msg['to_port']) + int(msg['to_starboard'])
|
||||
if width > 0:
|
||||
vessel['width'] = width
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Draught
|
||||
if 'draught' in msg:
|
||||
try:
|
||||
draught = float(msg['draught'])
|
||||
if draught > 0:
|
||||
vessel['draught'] = draught
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Rate of turn
|
||||
if 'turn' in msg:
|
||||
try:
|
||||
turn = float(msg['turn'])
|
||||
if -127 <= turn <= 127: # Valid range
|
||||
vessel['rate_of_turn'] = turn
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Message type for debugging
|
||||
if 'type' in msg:
|
||||
vessel['last_msg_type'] = msg['type']
|
||||
|
||||
# Timestamp
|
||||
vessel['last_seen'] = time.time()
|
||||
|
||||
return vessel
|
||||
|
||||
|
||||
@ais_bp.route('/tools')
|
||||
def check_ais_tools():
|
||||
"""Check for AIS decoding tools and hardware."""
|
||||
has_ais_catcher = find_ais_catcher() is not None
|
||||
|
||||
# Check what SDR hardware is detected
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
|
||||
return jsonify({
|
||||
'ais_catcher': has_ais_catcher,
|
||||
'ais_catcher_path': find_ais_catcher(),
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'device_count': len(devices)
|
||||
})
|
||||
|
||||
|
||||
@ais_bp.route('/status')
|
||||
def ais_status():
|
||||
"""Get AIS tracking status for debugging."""
|
||||
process_running = False
|
||||
if app_module.ais_process:
|
||||
process_running = app_module.ais_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': ais_running,
|
||||
'active_device': ais_active_device,
|
||||
'connected': ais_connected,
|
||||
'messages_received': ais_messages_received,
|
||||
'last_message_time': ais_last_message_time,
|
||||
'vessel_count': len(app_module.ais_vessels),
|
||||
'vessels': dict(app_module.ais_vessels),
|
||||
'queue_size': app_module.ais_queue.qsize(),
|
||||
'ais_catcher_path': find_ais_catcher(),
|
||||
'process_running': process_running
|
||||
})
|
||||
|
||||
|
||||
@ais_bp.route('/start', methods=['POST'])
|
||||
def start_ais():
|
||||
"""Start AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
|
||||
with app_module.ais_lock:
|
||||
if ais_running:
|
||||
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Find AIS-catcher
|
||||
ais_catcher_path = find_ais_catcher()
|
||||
if not ais_catcher_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
|
||||
}), 400
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Kill any existing process
|
||||
if app_module.ais_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.ais_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.ais_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.ais_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.ais_process = None
|
||||
logger.info("Killed existing AIS process")
|
||||
|
||||
# Build command using SDR abstraction
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
bias_t = data.get('bias_t', False)
|
||||
tcp_port = AIS_TCP_PORT
|
||||
|
||||
cmd = builder.build_ais_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain),
|
||||
bias_t=bias_t,
|
||||
tcp_port=tcp_port
|
||||
)
|
||||
|
||||
# Use the found AIS-catcher path
|
||||
cmd[0] = ais_catcher_path
|
||||
|
||||
try:
|
||||
logger.info(f"Starting AIS-catcher with device {device}: {' '.join(cmd)}")
|
||||
app_module.ais_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait for process to start
|
||||
time.sleep(2.0)
|
||||
|
||||
if app_module.ais_process.poll() is not None:
|
||||
stderr_output = ''
|
||||
if app_module.ais_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
ais_running = True
|
||||
ais_active_device = device
|
||||
|
||||
# Start TCP parser thread
|
||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'AIS tracking started',
|
||||
'device': device,
|
||||
'port': tcp_port
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@ais_bp.route('/stop', methods=['POST'])
|
||||
def stop_ais():
|
||||
"""Stop AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
|
||||
with app_module.ais_lock:
|
||||
if app_module.ais_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.ais_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.ais_process.wait(timeout=AIS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.ais_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.ais_process = None
|
||||
logger.info("AIS process stopped")
|
||||
ais_running = False
|
||||
ais_active_device = None
|
||||
|
||||
app_module.ais_vessels.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@ais_bp.route('/stream')
|
||||
def stream_ais():
|
||||
"""SSE stream for AIS vessels."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
return render_template('ais_dashboard.html')
|
||||
@@ -0,0 +1,788 @@
|
||||
"""
|
||||
Controller routes for managing remote Intercept agents.
|
||||
|
||||
This blueprint provides:
|
||||
- Agent CRUD operations
|
||||
- Proxy endpoints to forward requests to agents
|
||||
- Push data ingestion endpoint
|
||||
- Multi-agent SSE stream
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
from utils.database import (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||
)
|
||||
from utils.agent_client import (
|
||||
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.trilateration import (
|
||||
DeviceLocationTracker, PathLossModel, Trilateration,
|
||||
AgentObservation, estimate_location_from_observations
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent data queue for combined SSE stream
|
||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent CRUD
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/agents', methods=['GET'])
|
||||
def get_agents():
|
||||
"""List all registered agents."""
|
||||
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||
agents = list_agents(active_only=active_only)
|
||||
|
||||
# Optionally refresh status for each agent
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
if refresh:
|
||||
for agent in agents:
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
agent['healthy'] = client.health_check()
|
||||
except Exception:
|
||||
agent['healthy'] = False
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'agents': agents,
|
||||
'count': len(agents)
|
||||
})
|
||||
|
||||
|
||||
@controller_bp.route('/agents', methods=['POST'])
|
||||
def register_agent():
|
||||
"""
|
||||
Register a new remote agent.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"name": "sensor-node-1",
|
||||
"base_url": "http://192.168.1.50:8020",
|
||||
"api_key": "optional-shared-secret",
|
||||
"description": "Optional description"
|
||||
}
|
||||
"""
|
||||
data = request.json or {}
|
||||
|
||||
# Validate required fields
|
||||
name = data.get('name', '').strip()
|
||||
base_url = data.get('base_url', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
|
||||
if not base_url:
|
||||
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||
|
||||
# Check if agent already exists
|
||||
existing = get_agent_by_name(name)
|
||||
if existing:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent with name "{name}" already exists'
|
||||
}), 409
|
||||
|
||||
# Try to connect and get capabilities
|
||||
api_key = data.get('api_key', '').strip() or None
|
||||
client = AgentClient(base_url, api_key=api_key)
|
||||
|
||||
capabilities = None
|
||||
interfaces = None
|
||||
try:
|
||||
caps = client.get_capabilities()
|
||||
capabilities = caps.get('modes', {})
|
||||
interfaces = {'devices': caps.get('devices', [])}
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
logger.warning(f"Could not fetch capabilities from {base_url}: {e}")
|
||||
|
||||
# Create agent
|
||||
try:
|
||||
agent_id = create_agent(
|
||||
name=name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
description=data.get('description'),
|
||||
capabilities=capabilities,
|
||||
interfaces=interfaces
|
||||
)
|
||||
|
||||
# Update last_seen since we just connected
|
||||
if capabilities is not None:
|
||||
update_agent(agent_id, update_last_seen=True)
|
||||
|
||||
agent = get_agent(agent_id)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Agent registered successfully',
|
||||
'agent': agent
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create agent")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
||||
def get_agent_detail(agent_id: int):
|
||||
"""Get details of a specific agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
# Optionally refresh from agent
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
if refresh:
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
metadata = client.refresh_metadata()
|
||||
if metadata['healthy']:
|
||||
caps = metadata['capabilities'] or {}
|
||||
# Store full interfaces structure (wifi, bt, sdr)
|
||||
agent_interfaces = caps.get('interfaces', {})
|
||||
# Fallback: also include top-level devices for backwards compatibility
|
||||
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
|
||||
)
|
||||
agent = get_agent(agent_id)
|
||||
agent['healthy'] = True
|
||||
else:
|
||||
agent['healthy'] = False
|
||||
except Exception:
|
||||
agent['healthy'] = False
|
||||
|
||||
return jsonify({'status': 'success', 'agent': agent})
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>', methods=['PUT', 'PATCH'])
|
||||
def update_agent_detail(agent_id: int):
|
||||
"""Update an agent's details."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Update allowed fields
|
||||
update_agent(
|
||||
agent_id,
|
||||
base_url=data.get('base_url'),
|
||||
description=data.get('description'),
|
||||
api_key=data.get('api_key'),
|
||||
is_active=data.get('is_active')
|
||||
)
|
||||
|
||||
agent = get_agent(agent_id)
|
||||
return jsonify({'status': 'success', 'agent': agent})
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>', methods=['DELETE'])
|
||||
def remove_agent(agent_id: int):
|
||||
"""Delete an agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
delete_agent(agent_id)
|
||||
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/refresh', methods=['POST'])
|
||||
def refresh_agent_metadata(agent_id: int):
|
||||
"""Refresh an agent's capabilities and status."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
metadata = client.refresh_metadata()
|
||||
|
||||
if metadata['healthy']:
|
||||
caps = metadata['capabilities'] or {}
|
||||
# Store full interfaces structure (wifi, bt, sdr)
|
||||
agent_interfaces = caps.get('interfaces', {})
|
||||
# Fallback: also include top-level devices for backwards compatibility
|
||||
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
|
||||
)
|
||||
agent = get_agent(agent_id)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'agent': agent,
|
||||
'metadata': metadata
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Agent is not reachable'
|
||||
}), 503
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to reach agent: {e}'
|
||||
}), 503
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Status - Get running state
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/status', methods=['GET'])
|
||||
def get_agent_status(agent_id: int):
|
||||
"""Get an agent's current status including running modes."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
status = client.get_status()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'agent_id': agent_id,
|
||||
'agent_name': agent['name'],
|
||||
'agent_status': status
|
||||
})
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to reach agent: {e}'
|
||||
}), 503
|
||||
|
||||
|
||||
@controller_bp.route('/agents/health', methods=['GET'])
|
||||
def check_all_agents_health():
|
||||
"""
|
||||
Check health of all registered agents in one call.
|
||||
|
||||
More efficient than checking each agent individually.
|
||||
Returns health status, response time, and running modes for each agent.
|
||||
"""
|
||||
agents_list = list_agents(active_only=True)
|
||||
results = []
|
||||
|
||||
for agent in agents_list:
|
||||
result = {
|
||||
'id': agent['id'],
|
||||
'name': agent['name'],
|
||||
'healthy': False,
|
||||
'response_time_ms': None,
|
||||
'running_modes': [],
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
|
||||
# Time the health check
|
||||
start_time = time.time()
|
||||
is_healthy = client.health_check()
|
||||
response_time = (time.time() - start_time) * 1000
|
||||
|
||||
result['healthy'] = is_healthy
|
||||
result['response_time_ms'] = round(response_time, 1)
|
||||
|
||||
if is_healthy:
|
||||
# Update last_seen in database
|
||||
update_agent(agent['id'], update_last_seen=True)
|
||||
|
||||
# Also fetch running modes
|
||||
try:
|
||||
status = client.get_status()
|
||||
result['running_modes'] = status.get('running_modes', [])
|
||||
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||
except Exception:
|
||||
pass # Status fetch is optional
|
||||
|
||||
except AgentConnectionError as e:
|
||||
result['error'] = f'Connection failed: {str(e)}'
|
||||
except AgentHTTPError as e:
|
||||
result['error'] = f'HTTP error: {str(e)}'
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
|
||||
results.append(result)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'agents': results,
|
||||
'total': len(results),
|
||||
'healthy_count': sum(1 for r in results if r['healthy'])
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proxy Operations - Forward requests to agents
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/start', methods=['POST'])
|
||||
def proxy_start_mode(agent_id: int, mode: str):
|
||||
"""Start a mode on a remote agent."""
|
||||
agent = get_agent(agent_id)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||
|
||||
params = request.json or {}
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
result = client.start_mode(mode, params)
|
||||
|
||||
# Update last_seen
|
||||
update_agent(agent_id, update_last_seen=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
'result': result
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
||||
def proxy_stop_mode(agent_id: int, mode: str):
|
||||
"""Stop a mode on 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)
|
||||
result = client.stop_mode(mode)
|
||||
|
||||
update_agent(agent_id, update_last_seen=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
'result': result
|
||||
})
|
||||
|
||||
except AgentConnectionError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
||||
def proxy_mode_status(agent_id: int, mode: str):
|
||||
"""Get mode status 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)
|
||||
result = client.get_mode_status(mode)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'agent_id': agent_id,
|
||||
'mode': mode,
|
||||
'result': result
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 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
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
result = client.get_mode_data(mode)
|
||||
|
||||
# Tag data with agent info
|
||||
result['agent_id'] = agent_id
|
||||
result['agent_name'] = agent['name']
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'agent_id': agent_id,
|
||||
'agent_name': agent['name'],
|
||||
'mode': mode,
|
||||
'data': result
|
||||
})
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Agent error: {e}'
|
||||
}), 502
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Push Data Ingestion
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/api/ingest', methods=['POST'])
|
||||
def ingest_push_data():
|
||||
"""
|
||||
Receive pushed data from remote agents.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"agent_name": "sensor-node-1",
|
||||
"scan_type": "adsb",
|
||||
"interface": "rtlsdr0",
|
||||
"payload": {...},
|
||||
"received_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
|
||||
Expected header:
|
||||
X-API-Key: shared-secret (if agent has api_key configured)
|
||||
"""
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
agent_name = data.get('agent_name')
|
||||
if not agent_name:
|
||||
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
||||
|
||||
# Find agent
|
||||
agent = get_agent_by_name(agent_name)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
|
||||
|
||||
# Validate API key if configured
|
||||
if agent.get('api_key'):
|
||||
provided_key = request.headers.get('X-API-Key', '')
|
||||
if provided_key != agent['api_key']:
|
||||
logger.warning(f"Invalid API key from agent {agent_name}")
|
||||
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
|
||||
|
||||
# Store payload
|
||||
try:
|
||||
payload_id = store_push_payload(
|
||||
agent_id=agent['id'],
|
||||
scan_type=data.get('scan_type', 'unknown'),
|
||||
payload=data.get('payload', {}),
|
||||
interface=data.get('interface'),
|
||||
received_at=data.get('received_at')
|
||||
)
|
||||
|
||||
# Emit to SSE stream
|
||||
try:
|
||||
agent_data_queue.put_nowait({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
except queue.Full:
|
||||
logger.warning("Agent data queue full, data may be lost")
|
||||
|
||||
return jsonify({
|
||||
'status': 'accepted',
|
||||
'payload_id': payload_id
|
||||
}), 202
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to store push payload")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@controller_bp.route('/api/payloads', methods=['GET'])
|
||||
def get_payloads():
|
||||
"""Get recent push payloads."""
|
||||
agent_id = request.args.get('agent_id', type=int)
|
||||
scan_type = request.args.get('scan_type')
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
|
||||
payloads = get_recent_payloads(
|
||||
agent_id=agent_id,
|
||||
scan_type=scan_type,
|
||||
limit=min(limit, 1000)
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'payloads': payloads,
|
||||
'count': len(payloads)
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Multi-Agent SSE Stream
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/stream/all')
|
||||
def stream_all_agents():
|
||||
"""
|
||||
Combined SSE stream for data from all agents.
|
||||
|
||||
This endpoint streams push data as it arrives from agents.
|
||||
Each message is tagged with agent_id and agent_name.
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = agent_data_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Management Page
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/manage')
|
||||
def agent_management_page():
|
||||
"""Render the agent management page."""
|
||||
from flask import render_template
|
||||
from config import VERSION
|
||||
return render_template('agents.html', version=VERSION)
|
||||
|
||||
|
||||
@controller_bp.route('/monitor')
|
||||
def network_monitor_page():
|
||||
"""Render the network monitor page for multi-agent aggregated view."""
|
||||
from flask import render_template
|
||||
return render_template('network_monitor.html')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Device Location Estimation (Trilateration)
|
||||
# =============================================================================
|
||||
|
||||
# Global device location tracker
|
||||
device_tracker = DeviceLocationTracker(
|
||||
trilateration=Trilateration(
|
||||
path_loss_model=PathLossModel('outdoor'),
|
||||
min_observations=2
|
||||
),
|
||||
observation_window_seconds=120.0, # 2 minute window
|
||||
min_observations=2
|
||||
)
|
||||
|
||||
|
||||
@controller_bp.route('/api/location/observe', methods=['POST'])
|
||||
def add_location_observation():
|
||||
"""
|
||||
Add an observation for device location estimation.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||
"agent_name": "sensor-node-1",
|
||||
"agent_lat": 40.7128,
|
||||
"agent_lon": -74.0060,
|
||||
"rssi": -55,
|
||||
"frequency_mhz": 2400 (optional)
|
||||
}
|
||||
|
||||
Returns location estimate if enough data, null otherwise.
|
||||
"""
|
||||
data = request.json or {}
|
||||
|
||||
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
||||
for field in required:
|
||||
if field not in data:
|
||||
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
|
||||
|
||||
# Look up agent GPS from database if not provided
|
||||
agent_lat = data.get('agent_lat')
|
||||
agent_lon = data.get('agent_lon')
|
||||
|
||||
if agent_lat is None or agent_lon is None:
|
||||
agent = get_agent_by_name(data['agent_name'])
|
||||
if agent and agent.get('gps_coords'):
|
||||
coords = agent['gps_coords']
|
||||
agent_lat = coords.get('lat') or coords.get('latitude')
|
||||
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||
|
||||
if agent_lat is None or agent_lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Agent GPS coordinates required'
|
||||
}), 400
|
||||
|
||||
estimate = device_tracker.add_observation(
|
||||
device_id=data['device_id'],
|
||||
agent_name=data['agent_name'],
|
||||
agent_lat=float(agent_lat),
|
||||
agent_lon=float(agent_lon),
|
||||
rssi=float(data['rssi']),
|
||||
frequency_mhz=data.get('frequency_mhz')
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'device_id': data['device_id'],
|
||||
'location': estimate.to_dict() if estimate else None
|
||||
})
|
||||
|
||||
|
||||
@controller_bp.route('/api/location/estimate', methods=['POST'])
|
||||
def estimate_location():
|
||||
"""
|
||||
Estimate device location from provided observations.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"observations": [
|
||||
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
|
||||
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
|
||||
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"}
|
||||
],
|
||||
"environment": "outdoor" (optional: outdoor, indoor, free_space)
|
||||
}
|
||||
"""
|
||||
data = request.json or {}
|
||||
|
||||
observations = data.get('observations', [])
|
||||
if len(observations) < 2:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'At least 2 observations required'
|
||||
}), 400
|
||||
|
||||
environment = data.get('environment', 'outdoor')
|
||||
|
||||
try:
|
||||
result = estimate_location_from_observations(observations, environment)
|
||||
return jsonify({
|
||||
'status': 'success' if result else 'insufficient_data',
|
||||
'location': result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Location estimation failed")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
||||
def get_device_location(device_id: str):
|
||||
"""Get the latest location estimate for a device."""
|
||||
estimate = device_tracker.get_location(device_id)
|
||||
|
||||
if not estimate:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'device_id': device_id,
|
||||
'location': None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'device_id': device_id,
|
||||
'location': estimate.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@controller_bp.route('/api/location/all', methods=['GET'])
|
||||
def get_all_locations():
|
||||
"""Get all current device location estimates."""
|
||||
locations = device_tracker.get_all_locations()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'count': len(locations),
|
||||
'devices': {
|
||||
device_id: estimate.to_dict()
|
||||
for device_id, estimate in locations.items()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@controller_bp.route('/api/location/near', methods=['GET'])
|
||||
def get_devices_near():
|
||||
"""
|
||||
Find devices near a location.
|
||||
|
||||
Query params:
|
||||
lat: latitude
|
||||
lon: longitude
|
||||
radius: radius in meters (default 100)
|
||||
"""
|
||||
try:
|
||||
lat = float(request.args.get('lat', 0))
|
||||
lon = float(request.args.get('lon', 0))
|
||||
radius = float(request.args.get('radius', 100))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||
|
||||
results = device_tracker.get_devices_near(lat, lon, radius)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'center': {'lat': lat, 'lon': lon},
|
||||
'radius_meters': radius,
|
||||
'count': len(results),
|
||||
'devices': [
|
||||
{'device_id': device_id, 'location': estimate.to_dict()}
|
||||
for device_id, estimate in results
|
||||
]
|
||||
})
|
||||
@@ -0,0 +1,575 @@
|
||||
"""VHF DSC (Digital Selective Calling) routes.
|
||||
|
||||
DSC operates on VHF Channel 70 (156.525 MHz) for maritime
|
||||
distress and safety communications per ITU-R M.493.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
DSC_VHF_FREQUENCY_MHZ,
|
||||
DSC_SAMPLE_RATE,
|
||||
DSC_TERMINATE_TIMEOUT,
|
||||
)
|
||||
from utils.database import (
|
||||
store_dsc_alert,
|
||||
get_dsc_alerts,
|
||||
get_dsc_alert,
|
||||
acknowledge_dsc_alert,
|
||||
get_dsc_alert_summary,
|
||||
)
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
logger = logging.getLogger('intercept.dsc')
|
||||
|
||||
dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
|
||||
|
||||
# Module state (track if running independent of process state)
|
||||
dsc_running = False
|
||||
|
||||
|
||||
def _get_dsc_decoder_path() -> str | None:
|
||||
"""Get path to DSC decoder."""
|
||||
# Check for our custom decoder
|
||||
project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder')
|
||||
if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK):
|
||||
return project_bin
|
||||
|
||||
# Check system PATH
|
||||
system_decoder = shutil.which('dsc-decoder')
|
||||
if system_decoder:
|
||||
return system_decoder
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_dsc_tools() -> dict:
|
||||
"""Check availability of DSC decoding tools."""
|
||||
rtl_fm_path = get_tool_path('rtl_fm')
|
||||
decoder_path = _get_dsc_decoder_path()
|
||||
|
||||
# Check for scipy/numpy (needed for decoder)
|
||||
scipy_available = False
|
||||
try:
|
||||
import scipy
|
||||
import numpy
|
||||
scipy_available = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return {
|
||||
'rtl_fm': {
|
||||
'available': rtl_fm_path is not None,
|
||||
'path': rtl_fm_path
|
||||
},
|
||||
'dsc_decoder': {
|
||||
'available': decoder_path is not None,
|
||||
'path': decoder_path
|
||||
},
|
||||
'scipy': {
|
||||
'available': scipy_available,
|
||||
'note': 'Required for DSC signal processing'
|
||||
},
|
||||
'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available
|
||||
}
|
||||
|
||||
|
||||
def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> None:
|
||||
"""
|
||||
Stream DSC decoder output to queue using PTY for unbuffered output.
|
||||
|
||||
Args:
|
||||
master_fd: PTY master file descriptor
|
||||
decoder_process: Decoder subprocess
|
||||
"""
|
||||
global dsc_running
|
||||
|
||||
try:
|
||||
app_module.dsc_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
buffer = ""
|
||||
while dsc_running:
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
if ready:
|
||||
try:
|
||||
data = os.read(master_fd, 1024)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Parse DSC message
|
||||
parsed = parse_dsc_message(line)
|
||||
if parsed:
|
||||
# Generate unique message ID
|
||||
msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}"
|
||||
parsed['id'] = msg_id
|
||||
|
||||
# Store in transient DataStore
|
||||
app_module.dsc_messages.set(msg_id, parsed)
|
||||
|
||||
# Queue for SSE
|
||||
try:
|
||||
app_module.dsc_queue.put_nowait(parsed)
|
||||
except queue.Full:
|
||||
logger.warning("DSC queue full, dropping message")
|
||||
|
||||
# Store critical alerts permanently
|
||||
if parsed.get('is_critical'):
|
||||
_store_critical_alert(parsed)
|
||||
else:
|
||||
# Raw output for debugging
|
||||
app_module.dsc_queue.put({
|
||||
'type': 'raw',
|
||||
'text': line
|
||||
})
|
||||
except OSError:
|
||||
break
|
||||
|
||||
# Check if process is still running
|
||||
if decoder_process.poll() is not None:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DSC decoder error: {e}")
|
||||
app_module.dsc_queue.put({
|
||||
'type': 'error',
|
||||
'error': str(e)
|
||||
})
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
decoder_process.wait()
|
||||
dsc_running = False
|
||||
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
|
||||
|
||||
|
||||
def _store_critical_alert(msg: dict) -> None:
|
||||
"""Store critical DSC alert (DISTRESS/URGENCY) to database."""
|
||||
try:
|
||||
store_dsc_alert(
|
||||
source_mmsi=msg.get('source_mmsi', ''),
|
||||
format_code=str(msg.get('format_code', '')),
|
||||
category=msg.get('category', 'UNKNOWN'),
|
||||
source_name=msg.get('source_name'),
|
||||
dest_mmsi=msg.get('dest_mmsi'),
|
||||
nature_of_distress=msg.get('nature_of_distress'),
|
||||
latitude=msg.get('latitude'),
|
||||
longitude=msg.get('longitude'),
|
||||
raw_message=msg.get('raw_message')
|
||||
)
|
||||
logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store DSC alert: {e}")
|
||||
|
||||
|
||||
def monitor_rtl_stderr(process: subprocess.Popen) -> None:
|
||||
"""Monitor rtl_fm stderr for errors."""
|
||||
global dsc_running
|
||||
|
||||
try:
|
||||
for line in process.stderr:
|
||||
if not dsc_running:
|
||||
break
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text:
|
||||
logger.debug(f"[RTL_FM] {err_text}")
|
||||
|
||||
# Check for device busy error
|
||||
if 'usb_claim_interface' in err_text.lower():
|
||||
app_module.dsc_queue.put({
|
||||
'type': 'error',
|
||||
'error': 'SDR device busy',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'suggestion': 'Use a different SDR device or stop other SDR processes'
|
||||
})
|
||||
|
||||
# Check for other common errors
|
||||
if 'no supported devices' in err_text.lower():
|
||||
app_module.dsc_queue.put({
|
||||
'type': 'error',
|
||||
'error': 'No SDR device found',
|
||||
'error_type': 'NO_DEVICE'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@dsc_bp.route('/status')
|
||||
def get_status() -> Response:
|
||||
"""Get DSC decoder status."""
|
||||
global dsc_running
|
||||
|
||||
with app_module.dsc_lock:
|
||||
running = (
|
||||
dsc_running and
|
||||
app_module.dsc_process is not None and
|
||||
app_module.dsc_process.poll() is None
|
||||
)
|
||||
|
||||
# Get message counts
|
||||
message_count = len(app_module.dsc_messages)
|
||||
alert_summary = get_dsc_alert_summary()
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||
'message_count': message_count,
|
||||
'alerts': alert_summary
|
||||
})
|
||||
|
||||
|
||||
@dsc_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check DSC decoder tool availability."""
|
||||
tools = _check_dsc_tools()
|
||||
return jsonify(tools)
|
||||
|
||||
|
||||
@dsc_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
"""Start DSC decoder."""
|
||||
global dsc_running
|
||||
|
||||
with app_module.dsc_lock:
|
||||
if app_module.dsc_process and app_module.dsc_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'DSC decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check tools
|
||||
tools = _check_dsc_tools()
|
||||
if not tools['ready']:
|
||||
missing = []
|
||||
if not tools['rtl_fm']['available']:
|
||||
missing.append('rtl_fm')
|
||||
if not tools['dsc_decoder']['available']:
|
||||
missing.append('dsc-decoder')
|
||||
if not tools['scipy']['available']:
|
||||
missing.append('scipy/numpy')
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Missing required tools: {", ".join(missing)}'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate device
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
# Validate gain
|
||||
try:
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
except ValueError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'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
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
try:
|
||||
app_module.dsc_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build rtl_fm command
|
||||
rtl_fm_path = tools['rtl_fm']['path']
|
||||
decoder_path = tools['dsc_decoder']['path']
|
||||
|
||||
# rtl_fm command for DSC decoding
|
||||
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||
'-s', str(DSC_SAMPLE_RATE),
|
||||
'-d', str(device),
|
||||
'-g', str(gain),
|
||||
'-M', 'fm', # FM demodulation
|
||||
'-l', '0', # No squelch for DSC
|
||||
'-E', 'dc' # DC blocking filter
|
||||
]
|
||||
|
||||
# Decoder command
|
||||
decoder_cmd = [decoder_path]
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd)
|
||||
logger.info(f"Starting DSC decoder: {full_cmd}")
|
||||
|
||||
try:
|
||||
# Start rtl_fm subprocess
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start stderr monitor thread
|
||||
stderr_thread = threading.Thread(
|
||||
target=monitor_rtl_stderr,
|
||||
args=(rtl_process,),
|
||||
daemon=True
|
||||
)
|
||||
stderr_thread.start()
|
||||
|
||||
# Create PTY for decoder output
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Start decoder subprocess
|
||||
decoder_process = subprocess.Popen(
|
||||
decoder_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Store process references
|
||||
app_module.dsc_process = decoder_process
|
||||
app_module.dsc_rtl_process = rtl_process
|
||||
dsc_running = True
|
||||
|
||||
# Start output streaming thread
|
||||
output_thread = threading.Thread(
|
||||
target=stream_dsc_decoder,
|
||||
args=(master_fd, decoder_process),
|
||||
daemon=True
|
||||
)
|
||||
output_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||
'device': device,
|
||||
'gain': gain,
|
||||
'command': full_cmd
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Tool not found: {e.filename}'
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start DSC decoder: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@dsc_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
"""Stop DSC decoder."""
|
||||
global dsc_running
|
||||
|
||||
with app_module.dsc_lock:
|
||||
if not app_module.dsc_process:
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
dsc_running = False
|
||||
|
||||
# Terminate rtl_fm process first
|
||||
if app_module.dsc_rtl_process:
|
||||
try:
|
||||
app_module.dsc_rtl_process.terminate()
|
||||
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
app_module.dsc_rtl_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Terminate decoder process
|
||||
if app_module.dsc_process:
|
||||
try:
|
||||
app_module.dsc_process.terminate()
|
||||
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
app_module.dsc_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
app_module.dsc_process = None
|
||||
app_module.dsc_rtl_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@dsc_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
"""SSE stream for real-time DSC messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.dsc_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@dsc_bp.route('/messages')
|
||||
def get_messages() -> Response:
|
||||
"""Get current DSC messages from transient store."""
|
||||
messages = list(app_module.dsc_messages.values())
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'count': len(messages),
|
||||
'messages': messages
|
||||
})
|
||||
|
||||
|
||||
@dsc_bp.route('/alerts')
|
||||
def get_alerts_endpoint() -> Response:
|
||||
"""Get stored DSC alerts (paginated)."""
|
||||
# Parse query params
|
||||
category = request.args.get('category')
|
||||
acknowledged = request.args.get('acknowledged')
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
# Convert acknowledged param
|
||||
ack_filter = None
|
||||
if acknowledged is not None:
|
||||
ack_filter = acknowledged.lower() in ('true', '1', 'yes')
|
||||
|
||||
alerts = get_dsc_alerts(
|
||||
category=category,
|
||||
acknowledged=ack_filter,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
summary = get_dsc_alert_summary()
|
||||
|
||||
return jsonify({
|
||||
'alerts': alerts,
|
||||
'count': len(alerts),
|
||||
'summary': summary,
|
||||
'pagination': {
|
||||
'limit': limit,
|
||||
'offset': offset
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@dsc_bp.route('/alerts/<int:alert_id>')
|
||||
def get_alert(alert_id: int) -> Response:
|
||||
"""Get a specific DSC alert by ID."""
|
||||
alert = get_dsc_alert(alert_id)
|
||||
if not alert:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Alert not found'
|
||||
}), 404
|
||||
|
||||
return jsonify(alert)
|
||||
|
||||
|
||||
@dsc_bp.route('/alerts/<int:alert_id>/acknowledge', methods=['POST'])
|
||||
def acknowledge_alert(alert_id: int) -> Response:
|
||||
"""Acknowledge a DSC alert."""
|
||||
data = request.json or {}
|
||||
notes = data.get('notes')
|
||||
|
||||
success = acknowledge_dsc_alert(alert_id, notes)
|
||||
if not success:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Alert not found'
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'acknowledged',
|
||||
'alert_id': alert_id
|
||||
})
|
||||
|
||||
|
||||
@dsc_bp.route('/alerts/summary')
|
||||
def get_alerts_summary() -> Response:
|
||||
"""Get summary of unacknowledged DSC alerts."""
|
||||
summary = get_dsc_alert_summary()
|
||||
return jsonify(summary)
|
||||
@@ -161,32 +161,6 @@ def get_position():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/debug')
|
||||
def debug_gps():
|
||||
"""Debug endpoint showing GPS client state."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
return jsonify({
|
||||
'reader': None,
|
||||
'message': 'No GPS client initialized'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': 'gpsd',
|
||||
'device': reader.device_path,
|
||||
'host': reader.host,
|
||||
'port': reader.port,
|
||||
'has_position': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
'error': reader.error,
|
||||
'callbacks_registered': len(reader._callbacks),
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position updates."""
|
||||
|
||||
@@ -398,6 +398,8 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
# Explicitly output to stdout (some rtl_fm versions need this)
|
||||
sdr_cmd.append('-')
|
||||
else:
|
||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||
rx_fm_path = find_rx_fm()
|
||||
@@ -438,9 +440,12 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
]
|
||||
|
||||
try:
|
||||
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
|
||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
|
||||
logger.info(f"Starting audio pipeline: {shell_cmd}")
|
||||
# Use shell pipe for reliable streaming
|
||||
# Log stderr to temp files for error diagnosis
|
||||
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
||||
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
|
||||
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
|
||||
|
||||
audio_rtl_process = None # Not used in shell mode
|
||||
audio_process = subprocess.Popen(
|
||||
@@ -456,8 +461,20 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
time.sleep(0.3)
|
||||
|
||||
if audio_process.poll() is not None:
|
||||
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
|
||||
logger.error(f"Audio pipeline exited immediately: {stderr}")
|
||||
# Read stderr from temp files
|
||||
rtl_stderr = ''
|
||||
ffmpeg_stderr = ''
|
||||
try:
|
||||
with open(rtl_stderr_log, 'r') as f:
|
||||
rtl_stderr = f.read().strip()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
with open(ffmpeg_stderr_log, 'r') as f:
|
||||
ffmpeg_stderr = f.read().strip()
|
||||
except:
|
||||
pass
|
||||
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
|
||||
return
|
||||
|
||||
audio_running = True
|
||||
@@ -775,8 +792,6 @@ def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running
|
||||
|
||||
logger.info("Audio start request received")
|
||||
|
||||
# Stop scanner if running
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
@@ -868,20 +883,28 @@ def stream_audio() -> Response:
|
||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||
|
||||
def generate():
|
||||
# Capture local reference to avoid race condition with stop
|
||||
proc = audio_process
|
||||
if not proc or not proc.stdout:
|
||||
return
|
||||
try:
|
||||
while audio_running and audio_process and audio_process.poll() is None:
|
||||
while audio_running and proc.poll() is None:
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_process.stdout.read(4096)
|
||||
chunk = proc.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# Timeout - check if process died
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Audio stream error: {e}")
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
"""Meshtastic mesh network routes.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.meshtastic import (
|
||||
get_meshtastic_client,
|
||||
start_meshtastic,
|
||||
stop_meshtastic,
|
||||
is_meshtastic_available,
|
||||
MeshtasticMessage,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.meshtastic')
|
||||
|
||||
meshtastic_bp = Blueprint('meshtastic', __name__, url_prefix='/meshtastic')
|
||||
|
||||
# Queue for SSE message streaming
|
||||
_mesh_queue: queue.Queue = queue.Queue(maxsize=500)
|
||||
|
||||
# Store recent messages for history
|
||||
_recent_messages: list[dict] = []
|
||||
MAX_HISTORY = 500
|
||||
|
||||
|
||||
def _message_callback(msg: MeshtasticMessage) -> None:
|
||||
"""Callback to queue messages for SSE stream."""
|
||||
msg_dict = msg.to_dict()
|
||||
|
||||
# Add to history
|
||||
_recent_messages.append(msg_dict)
|
||||
if len(_recent_messages) > MAX_HISTORY:
|
||||
_recent_messages.pop(0)
|
||||
|
||||
# Queue for SSE
|
||||
try:
|
||||
_mesh_queue.put_nowait(msg_dict)
|
||||
except queue.Full:
|
||||
try:
|
||||
_mesh_queue.get_nowait()
|
||||
_mesh_queue.put_nowait(msg_dict)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@meshtastic_bp.route('/status')
|
||||
def get_status():
|
||||
"""
|
||||
Get Meshtastic connection status.
|
||||
|
||||
Returns:
|
||||
JSON with connection status, device info, and node information.
|
||||
"""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({
|
||||
'available': False,
|
||||
'running': False,
|
||||
'error': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
|
||||
})
|
||||
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client:
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'running': False,
|
||||
'device': None,
|
||||
'node_info': None,
|
||||
})
|
||||
|
||||
node_info = client.get_node_info() if client.is_running else None
|
||||
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'running': client.is_running,
|
||||
'device': client.device_path,
|
||||
'error': client.error,
|
||||
'node_info': node_info.to_dict() if node_info else None,
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/start', methods=['POST'])
|
||||
def start_mesh():
|
||||
"""
|
||||
Start Meshtastic listener.
|
||||
|
||||
Connects to a Meshtastic device and begins receiving messages.
|
||||
The device must be connected via USB/Serial.
|
||||
|
||||
JSON body (optional):
|
||||
{
|
||||
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with connection status.
|
||||
"""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
|
||||
}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
if client and client.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'device': client.device_path
|
||||
})
|
||||
|
||||
# Clear queue and history
|
||||
while not _mesh_queue.empty():
|
||||
try:
|
||||
_mesh_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
_recent_messages.clear()
|
||||
|
||||
# Get optional device path
|
||||
data = request.get_json(silent=True) or {}
|
||||
device = data.get('device')
|
||||
|
||||
# Validate 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)
|
||||
|
||||
if success:
|
||||
client = get_meshtastic_client()
|
||||
node_info = client.get_node_info() if client else None
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'device': client.device_path if client else None,
|
||||
'node_info': node_info.to_dict() if node_info else None,
|
||||
})
|
||||
else:
|
||||
client = get_meshtastic_client()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': client.error if client else 'Failed to connect to Meshtastic device'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/stop', methods=['POST'])
|
||||
def stop_mesh():
|
||||
"""
|
||||
Stop Meshtastic listener.
|
||||
|
||||
Disconnects from the Meshtastic device and stops receiving messages.
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
stop_meshtastic()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/channels')
|
||||
def get_channels():
|
||||
"""
|
||||
Get configured channels on the connected device.
|
||||
|
||||
Returns:
|
||||
JSON with list of channel configurations.
|
||||
Note: PSK values are not returned for security - only encryption status.
|
||||
"""
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client or not client.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not connected to Meshtastic device'
|
||||
}), 400
|
||||
|
||||
channels = client.get_channels()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'channels': [ch.to_dict() for ch in channels],
|
||||
'count': len(channels)
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/channels/<int:index>', methods=['POST'])
|
||||
def configure_channel(index: int):
|
||||
"""
|
||||
Configure a channel with name and/or encryption key.
|
||||
|
||||
This allows joining encrypted channels by providing the PSK.
|
||||
The configuration is written to the connected Meshtastic device.
|
||||
|
||||
Args:
|
||||
index: Channel index (0-7). Channel 0 is typically the primary channel.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"name": "MyChannel", // Optional: Channel name
|
||||
"psk": "base64:ABC123..." // Optional: Encryption key
|
||||
}
|
||||
|
||||
PSK formats:
|
||||
- "none" : Disable encryption
|
||||
- "default" : Use default public key (NOT SECURE - known key)
|
||||
- "random" : Generate new random AES-256 key
|
||||
- "base64:..." : Base64-encoded 16-byte (AES-128) or 32-byte (AES-256) key
|
||||
- "0x..." : Hex-encoded key
|
||||
- "simple:passphrase" : Derive AES-256 key from passphrase using SHA-256
|
||||
|
||||
Returns:
|
||||
JSON with configuration result.
|
||||
|
||||
Security note:
|
||||
The "default" key is publicly known (shipped in source code).
|
||||
Use "random" or provide your own key for secure communications.
|
||||
"""
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client or not client.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not connected to Meshtastic device'
|
||||
}), 400
|
||||
|
||||
if not 0 <= index <= 7:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Channel index must be 0-7'
|
||||
}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = data.get('name')
|
||||
psk = data.get('psk')
|
||||
|
||||
if not name and not psk:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Must provide name and/or psk'
|
||||
}), 400
|
||||
|
||||
# Sanitize name if provided
|
||||
if name:
|
||||
name = str(name).strip()[:12] # Meshtastic channel names max 12 chars
|
||||
|
||||
# Validate PSK format if provided
|
||||
if psk:
|
||||
psk = str(psk).strip()
|
||||
|
||||
success, message = client.set_channel(index, name=name, psk=psk)
|
||||
|
||||
if success:
|
||||
# Return updated channel info
|
||||
channels = client.get_channels()
|
||||
updated = next((ch for ch in channels if ch.index == index), None)
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'message': message,
|
||||
'channel': updated.to_dict() if updated else None
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/send', methods=['POST'])
|
||||
def send_message():
|
||||
"""
|
||||
Send a text message to the mesh network.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"text": "Hello mesh!", // Required: message text (max 237 chars)
|
||||
"channel": 0, // Optional: channel index (default 0)
|
||||
"to": "!a1b2c3d4" // Optional: destination node (default broadcast)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with send status.
|
||||
"""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Meshtastic SDK not installed'
|
||||
}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client or not client.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not connected to Meshtastic device'
|
||||
}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get('text', '').strip()
|
||||
|
||||
if not text:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Message text is required'
|
||||
}), 400
|
||||
|
||||
if len(text) > 237:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Message too long (max 237 characters)'
|
||||
}), 400
|
||||
|
||||
channel = data.get('channel', 0)
|
||||
if not isinstance(channel, int) or not 0 <= channel <= 7:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Channel must be 0-7'
|
||||
}), 400
|
||||
|
||||
destination = data.get('to')
|
||||
|
||||
logger.info(f"Sending message: text='{text[:50]}...', channel={channel}, to={destination}")
|
||||
success, error = client.send_text(text, channel=channel, destination=destination)
|
||||
logger.info(f"Send result: success={success}, error={error}")
|
||||
|
||||
if success:
|
||||
return jsonify({'status': 'sent'})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': error or 'Failed to send message'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/messages')
|
||||
def get_messages():
|
||||
"""
|
||||
Get recent message history.
|
||||
|
||||
Returns the most recent messages received since the listener was started.
|
||||
Limited to the last 500 messages.
|
||||
|
||||
Query parameters:
|
||||
limit: Maximum number of messages to return (default: all)
|
||||
channel: Filter by channel index (optional)
|
||||
|
||||
Returns:
|
||||
JSON with message list.
|
||||
"""
|
||||
limit = request.args.get('limit', type=int)
|
||||
channel = request.args.get('channel', type=int)
|
||||
|
||||
messages = _recent_messages.copy()
|
||||
|
||||
# Filter by channel if specified
|
||||
if channel is not None:
|
||||
messages = [m for m in messages if m.get('channel') == channel]
|
||||
|
||||
# Apply limit
|
||||
if limit and limit > 0:
|
||||
messages = messages[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'messages': messages,
|
||||
'count': len(messages)
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
"""
|
||||
SSE stream of Meshtastic messages.
|
||||
|
||||
Provides real-time Server-Sent Events stream of incoming messages.
|
||||
Connect to this endpoint with EventSource to receive live updates.
|
||||
|
||||
Event format:
|
||||
data: {"type": "meshtastic", "from": "!a1b2c3d4", "message": "Hello", ...}
|
||||
|
||||
Keepalive events are sent every 30 seconds to maintain the connection.
|
||||
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = _mesh_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@meshtastic_bp.route('/node')
|
||||
def get_node():
|
||||
"""
|
||||
Get local node information.
|
||||
|
||||
Returns information about the connected Meshtastic device including
|
||||
its ID, name, hardware model, and current position (if available).
|
||||
|
||||
Returns:
|
||||
JSON with node information.
|
||||
"""
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client or not client.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not connected to Meshtastic device'
|
||||
}), 400
|
||||
|
||||
node_info = client.get_node_info()
|
||||
|
||||
if node_info:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'node': node_info.to_dict()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to get node information'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/nodes')
|
||||
def get_nodes():
|
||||
"""
|
||||
Get all tracked mesh nodes with their positions.
|
||||
|
||||
Returns all nodes that have been seen on the mesh network,
|
||||
including their positions (if reported), battery levels, and signal info.
|
||||
|
||||
Query parameters:
|
||||
with_position: If 'true', only return nodes with valid positions
|
||||
|
||||
Returns:
|
||||
JSON with list of nodes.
|
||||
"""
|
||||
client = get_meshtastic_client()
|
||||
|
||||
if not client or not client.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not connected to Meshtastic device',
|
||||
'nodes': []
|
||||
}), 400
|
||||
|
||||
nodes = client.get_nodes()
|
||||
nodes_list = [n.to_dict() for n in nodes]
|
||||
|
||||
# Filter to only nodes with positions if requested
|
||||
with_position = request.args.get('with_position', '').lower() == 'true'
|
||||
if with_position:
|
||||
nodes_list = [n for n in nodes_list if n.get('has_position')]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'nodes': nodes_list,
|
||||
'count': len(nodes_list),
|
||||
'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Offline mode routes - Asset management and settings for offline operation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from utils.database import get_setting, set_setting
|
||||
import os
|
||||
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'openstreetmap',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
'leaflet': [
|
||||
'static/vendor/leaflet/leaflet.js',
|
||||
'static/vendor/leaflet/leaflet.css'
|
||||
],
|
||||
'chartjs': [
|
||||
'static/vendor/chartjs/chart.umd.min.js'
|
||||
],
|
||||
'inter': [
|
||||
'static/vendor/fonts/Inter-Regular.woff2',
|
||||
'static/vendor/fonts/Inter-Medium.woff2',
|
||||
'static/vendor/fonts/Inter-SemiBold.woff2',
|
||||
'static/vendor/fonts/Inter-Bold.woff2'
|
||||
],
|
||||
'jetbrains': [
|
||||
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
|
||||
],
|
||||
'leaflet_images': [
|
||||
'static/vendor/leaflet/images/marker-icon.png',
|
||||
'static/vendor/leaflet/images/marker-icon-2x.png',
|
||||
'static/vendor/leaflet/images/marker-shadow.png',
|
||||
'static/vendor/leaflet/images/layers.png',
|
||||
'static/vendor/leaflet/images/layers-2x.png'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def get_offline_settings():
|
||||
"""Get all offline settings with defaults."""
|
||||
settings = {}
|
||||
for key, default in OFFLINE_DEFAULTS.items():
|
||||
settings[key] = get_setting(key, default)
|
||||
return settings
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['GET'])
|
||||
def get_settings():
|
||||
"""Get current offline settings."""
|
||||
settings = get_offline_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['POST'])
|
||||
def save_setting():
|
||||
"""Save an offline setting."""
|
||||
data = request.get_json()
|
||||
if not data or 'key' not in data or 'value' not in data:
|
||||
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
||||
|
||||
key = data['key']
|
||||
value = data['value']
|
||||
|
||||
# Validate key is an allowed setting
|
||||
if key not in OFFLINE_DEFAULTS:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
|
||||
|
||||
# Validate value type matches default
|
||||
default_type = type(OFFLINE_DEFAULTS[key])
|
||||
if not isinstance(value, default_type):
|
||||
# Try to convert
|
||||
try:
|
||||
if default_type == bool:
|
||||
value = str(value).lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
value = default_type(value)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid value type for {key}'
|
||||
}), 400
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
|
||||
|
||||
@offline_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Check status of local assets."""
|
||||
# Get the app root directory
|
||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
results = {}
|
||||
all_available = True
|
||||
|
||||
for asset_name, paths in ASSET_PATHS.items():
|
||||
available = True
|
||||
missing = []
|
||||
for path in paths:
|
||||
full_path = os.path.join(app_root, path)
|
||||
if not os.path.exists(full_path):
|
||||
available = False
|
||||
missing.append(path)
|
||||
|
||||
results[asset_name] = {
|
||||
'available': available,
|
||||
'missing': missing if not available else []
|
||||
}
|
||||
|
||||
if not available:
|
||||
all_available = False
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'all_available': all_available,
|
||||
'assets': results,
|
||||
'offline_enabled': get_setting('offline.enabled', False)
|
||||
})
|
||||
|
||||
|
||||
@offline_bp.route('/check-asset', methods=['GET'])
|
||||
def check_asset():
|
||||
"""Check if a specific asset file exists."""
|
||||
path = request.args.get('path', '')
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
||||
|
||||
# Security: only allow checking within static/vendor
|
||||
if not path.startswith('/static/vendor/'):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
|
||||
|
||||
# Remove leading slash and construct full path
|
||||
relative_path = path.lstrip('/')
|
||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
full_path = os.path.join(app_root, relative_path)
|
||||
|
||||
exists = os.path.exists(full_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'path': path,
|
||||
'exists': exists
|
||||
})
|
||||
@@ -25,6 +25,7 @@ from utils.validation import (
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
@@ -245,7 +246,10 @@ def start_decoding() -> Response:
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
multimon_path = get_tool_path('multimon-ng')
|
||||
if not multimon_path:
|
||||
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
|
||||
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""RTLAMR utility meter monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
|
||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||
|
||||
# Store rtl_tcp process separately
|
||||
rtl_tcp_process = None
|
||||
rtl_tcp_lock = threading.Lock()
|
||||
|
||||
|
||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtlamr JSON output to queue."""
|
||||
try:
|
||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtlamr outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'rtlamr'
|
||||
app_module.rtlamr_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | RTLAMR | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.rtlamr_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.rtlamr_lock:
|
||||
app_module.rtlamr_process = None
|
||||
|
||||
|
||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||
def start_rtlamr() -> Response:
|
||||
global rtl_tcp_process
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '912.0'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Clear queue
|
||||
while not app_module.rtlamr_queue.empty():
|
||||
try:
|
||||
app_module.rtlamr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get message type (default to scm)
|
||||
msgtype = data.get('msgtype', 'scm')
|
||||
output_format = data.get('format', 'json')
|
||||
|
||||
# Start rtl_tcp first
|
||||
with rtl_tcp_lock:
|
||||
if not rtl_tcp_process:
|
||||
logger.info("Starting rtl_tcp server...")
|
||||
try:
|
||||
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
|
||||
|
||||
# Add device index if not 0
|
||||
if device and device != '0':
|
||||
rtl_tcp_cmd.extend(['-d', str(device)])
|
||||
|
||||
# Add gain if not auto
|
||||
if gain and gain != '0':
|
||||
rtl_tcp_cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if not 0
|
||||
if ppm and ppm != '0':
|
||||
rtl_tcp_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
rtl_tcp_process = subprocess.Popen(
|
||||
rtl_tcp_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Wait a moment for rtl_tcp to start
|
||||
time.sleep(3)
|
||||
|
||||
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||
|
||||
# Build rtlamr command
|
||||
cmd = [
|
||||
'rtlamr',
|
||||
'-server=127.0.0.1:1234',
|
||||
f'-msgtype={msgtype}',
|
||||
f'-format={output_format}',
|
||||
f'-centerfreq={int(float(freq) * 1e6)}'
|
||||
]
|
||||
|
||||
# Add filter options if provided
|
||||
filterid = data.get('filterid')
|
||||
if filterid:
|
||||
cmd.append(f'-filterid={filterid}')
|
||||
|
||||
filtertype = data.get('filtertype')
|
||||
if filtertype:
|
||||
cmd.append(f'-filtertype={filtertype}')
|
||||
|
||||
# Unique messages only
|
||||
if data.get('unique', True):
|
||||
cmd.append('-unique=true')
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
try:
|
||||
app_module.rtlamr_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
def monitor_stderr():
|
||||
for line in app_module.rtlamr_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err:
|
||||
logger.debug(f"[rtlamr] {err}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'[rtlamr] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# If rtlamr fails, clean up rtl_tcp
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = 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
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||
def stop_rtlamr() -> Response:
|
||||
global rtl_tcp_process
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
app_module.rtlamr_process.terminate()
|
||||
try:
|
||||
app_module.rtlamr_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.rtlamr_process.kill()
|
||||
app_module.rtlamr_process = None
|
||||
|
||||
# Also stop rtl_tcp
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
try:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtl_tcp_process.kill()
|
||||
rtl_tcp_process = None
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stream_rtlamr')
|
||||
def stream_rtlamr() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
@@ -164,3 +168,123 @@ def get_device_correlations() -> Response:
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RTL-SDR DVB Driver Management
|
||||
# =============================================================================
|
||||
|
||||
DVB_MODULES = ['dvb_usb_rtl28xxu', 'rtl2832_sdr', 'rtl2832', 'rtl2830', 'r820t']
|
||||
BLACKLIST_FILE = '/etc/modprobe.d/blacklist-rtlsdr.conf'
|
||||
|
||||
|
||||
@settings_bp.route('/rtlsdr/driver-status', methods=['GET'])
|
||||
def check_dvb_driver_status() -> Response:
|
||||
"""Check if DVB kernel drivers are loaded and blocking RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'platform': sys.platform,
|
||||
'issue_detected': False,
|
||||
'message': 'DVB driver conflict only affects Linux systems'
|
||||
})
|
||||
|
||||
# Check which DVB modules are currently loaded
|
||||
loaded_modules = []
|
||||
try:
|
||||
result = subprocess.run(['lsmod'], capture_output=True, text=True, timeout=5)
|
||||
lsmod_output = result.stdout
|
||||
for mod in DVB_MODULES:
|
||||
if mod in lsmod_output:
|
||||
loaded_modules.append(mod)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check loaded modules: {e}")
|
||||
|
||||
# Check if blacklist file exists
|
||||
blacklist_exists = os.path.exists(BLACKLIST_FILE)
|
||||
|
||||
# Check blacklist file contents
|
||||
blacklist_contents = []
|
||||
if blacklist_exists:
|
||||
try:
|
||||
with open(BLACKLIST_FILE, 'r') as f:
|
||||
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
issue_detected = len(loaded_modules) > 0
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'platform': 'linux',
|
||||
'issue_detected': issue_detected,
|
||||
'loaded_modules': loaded_modules,
|
||||
'blacklist_file_exists': blacklist_exists,
|
||||
'blacklist_contents': blacklist_contents,
|
||||
'message': 'DVB drivers are claiming RTL-SDR devices' if issue_detected else 'No DVB driver conflict detected'
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route('/rtlsdr/blacklist-drivers', methods=['POST'])
|
||||
def blacklist_dvb_drivers() -> Response:
|
||||
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'This feature is only available on Linux'
|
||||
}), 400
|
||||
|
||||
# Check if we have permission (need to be running as root or with sudo)
|
||||
if os.geteuid() != 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
|
||||
}), 403
|
||||
|
||||
errors = []
|
||||
successes = []
|
||||
|
||||
# Create blacklist file if it doesn't exist
|
||||
if not os.path.exists(BLACKLIST_FILE):
|
||||
try:
|
||||
blacklist_content = """# RTL-SDR blacklist - prevents DVB drivers from claiming RTL-SDR devices
|
||||
# Created by INTERCEPT
|
||||
blacklist dvb_usb_rtl28xxu
|
||||
blacklist rtl2832
|
||||
blacklist rtl2830
|
||||
blacklist r820t
|
||||
"""
|
||||
with open(BLACKLIST_FILE, 'w') as f:
|
||||
f.write(blacklist_content)
|
||||
successes.append(f'Created {BLACKLIST_FILE}')
|
||||
except Exception as e:
|
||||
errors.append(f'Failed to create blacklist file: {e}')
|
||||
|
||||
# Unload the modules
|
||||
for mod in DVB_MODULES:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['modprobe', '-r', mod],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
successes.append(f'Unloaded module: {mod}')
|
||||
# returncode != 0 is OK - module might not be loaded
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not unload {mod}: {e}")
|
||||
|
||||
if errors:
|
||||
return jsonify({
|
||||
'status': 'partial',
|
||||
'message': 'Some operations failed. Please unplug and replug your RTL-SDR device.',
|
||||
'successes': successes,
|
||||
'errors': errors
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.',
|
||||
'successes': successes
|
||||
})
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
"""Spy Stations routes - Number stations and diplomatic HF networks."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations')
|
||||
|
||||
# Active spy stations data from priyom.org
|
||||
STATIONS = [
|
||||
# Number Stations (Intelligence)
|
||||
{
|
||||
"id": "e06",
|
||||
"name": "E06",
|
||||
"nickname": "English Man",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Weekdays, 2 transmissions 1 hour apart",
|
||||
"source_url": "https://priyom.org/number-stations/english/e06"
|
||||
},
|
||||
{
|
||||
"id": "s06",
|
||||
"name": "S06",
|
||||
"nickname": "Russian Man",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Same schedule as E06, alternating languages",
|
||||
"source_url": "https://priyom.org/number-stations/russian/s06"
|
||||
},
|
||||
{
|
||||
"id": "uvb76",
|
||||
"name": "UVB-76",
|
||||
"nickname": "The Buzzer",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4625, "primary": True},
|
||||
{"freq_khz": 5779, "primary": False},
|
||||
{"freq_khz": 6810, "primary": False},
|
||||
{"freq_khz": 7490, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.",
|
||||
"operator": "Russian Military",
|
||||
"schedule": "24/7 continuous operation",
|
||||
"source_url": "https://priyom.org/number-stations/russia/uvb-76"
|
||||
},
|
||||
{
|
||||
"id": "hm01",
|
||||
"name": "HM01",
|
||||
"nickname": "Cuban Numbers",
|
||||
"type": "number",
|
||||
"country": "Cuba",
|
||||
"country_code": "CU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 9065, "primary": True},
|
||||
{"freq_khz": 9155, "primary": False},
|
||||
{"freq_khz": 9240, "primary": False},
|
||||
{"freq_khz": 9330, "primary": False},
|
||||
{"freq_khz": 10345, "primary": False},
|
||||
{"freq_khz": 10715, "primary": False},
|
||||
{"freq_khz": 10860, "primary": False},
|
||||
{"freq_khz": 11435, "primary": False},
|
||||
{"freq_khz": 11462, "primary": False},
|
||||
{"freq_khz": 11530, "primary": False},
|
||||
{"freq_khz": 11635, "primary": False},
|
||||
{"freq_khz": 12180, "primary": False},
|
||||
{"freq_khz": 13435, "primary": False},
|
||||
{"freq_khz": 14375, "primary": False},
|
||||
{"freq_khz": 16180, "primary": False},
|
||||
{"freq_khz": 17480, "primary": False},
|
||||
],
|
||||
"mode": "AM/OFDM",
|
||||
"description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.",
|
||||
"operator": "DGI (Cuban Intelligence)",
|
||||
"schedule": "Multiple daily transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/cuba/hm01"
|
||||
},
|
||||
{
|
||||
"id": "e07",
|
||||
"name": "E07",
|
||||
"nickname": "7-dash",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5292, "primary": True},
|
||||
{"freq_khz": 6388, "primary": False},
|
||||
{"freq_khz": 7482, "primary": False},
|
||||
{"freq_khz": 8576, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian intelligence station using distinctive 7-dash interval signal. Female voice reading 5-figure groups in English. Part of the 'Russian 7' operator network.",
|
||||
"operator": "Russian 7",
|
||||
"schedule": "Irregular, typically evenings UTC",
|
||||
"source_url": "https://priyom.org/number-stations/english/e07"
|
||||
},
|
||||
{
|
||||
"id": "e11",
|
||||
"name": "E11",
|
||||
"nickname": "Mazielka",
|
||||
"type": "number",
|
||||
"country": "Poland",
|
||||
"country_code": "PL",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4030, "primary": True},
|
||||
{"freq_khz": 5240, "primary": False},
|
||||
{"freq_khz": 6910, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Polish intelligence number station. Female voice reads 5-figure groups in English. Named after distinctive melody interval signal.",
|
||||
"operator": "ABW (Polish Intelligence)",
|
||||
"schedule": "Weekly transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/english/e11"
|
||||
},
|
||||
{
|
||||
"id": "e17z",
|
||||
"name": "E17z",
|
||||
"nickname": "Israeli Numbers",
|
||||
"type": "number",
|
||||
"country": "Israel",
|
||||
"country_code": "IL",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4779, "primary": True},
|
||||
{"freq_khz": 5091, "primary": False},
|
||||
{"freq_khz": 6446, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Israeli intelligence number station. Female voice with distinctive Hebrew-accented English. Transmits 5-figure groups with phonetic alphabet.",
|
||||
"operator": "Mossad (suspected)",
|
||||
"schedule": "Irregular schedule",
|
||||
"source_url": "https://priyom.org/number-stations/english/e17z"
|
||||
},
|
||||
{
|
||||
"id": "g06",
|
||||
"name": "G06",
|
||||
"nickname": "Russian German",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "German language mode of Russian 6 operator. Male synthesized voice reads 5-figure groups in German. Shares frequencies with E06/S06.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Same schedule as E06",
|
||||
"source_url": "https://priyom.org/number-stations/german/g06"
|
||||
},
|
||||
{
|
||||
"id": "v02a",
|
||||
"name": "V02a",
|
||||
"nickname": "Cuban Spy Numbers",
|
||||
"type": "number",
|
||||
"country": "Cuba",
|
||||
"country_code": "CU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5855, "primary": True},
|
||||
{"freq_khz": 9330, "primary": False},
|
||||
{"freq_khz": 11635, "primary": False},
|
||||
],
|
||||
"mode": "AM",
|
||||
"description": "Cuban intelligence station using AM mode. Female Spanish voice reading 4-figure groups. Related to HM01 but separate schedule.",
|
||||
"operator": "DGI (Cuban Intelligence)",
|
||||
"schedule": "Evening transmissions, weekdays",
|
||||
"source_url": "https://priyom.org/number-stations/spanish/v02a"
|
||||
},
|
||||
{
|
||||
"id": "v07",
|
||||
"name": "V07",
|
||||
"nickname": "Russian 7 Voice",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 3756, "primary": True},
|
||||
{"freq_khz": 4625, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian voice number station. Female voice reads 5-figure groups in Russian. Part of Russian 7 operator network. Often shares 4625 kHz with UVB-76.",
|
||||
"operator": "Russian 7",
|
||||
"schedule": "Irregular transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/russian/v07"
|
||||
},
|
||||
{
|
||||
"id": "s11a",
|
||||
"name": "S11a",
|
||||
"nickname": "Russian Phonetic",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4560, "primary": True},
|
||||
{"freq_khz": 5200, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian phonetic alphabet number station. Male voice reads 5-letter groups using Russian phonetic alphabet (Anna, Boris, etc.).",
|
||||
"operator": "GRU (suspected)",
|
||||
"schedule": "Weekly scheduled transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/russian/s11a"
|
||||
},
|
||||
{
|
||||
"id": "v13",
|
||||
"name": "V13",
|
||||
"nickname": "The Pip",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 3756, "primary": True},
|
||||
{"freq_khz": 5448, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian military channel marker known as 'The Pip'. Continuous short beep every 1 second with occasional voice messages. Sister station to UVB-76.",
|
||||
"operator": "Russian Military",
|
||||
"schedule": "24/7 continuous operation",
|
||||
"source_url": "https://priyom.org/military-stations/russia/the-pip"
|
||||
},
|
||||
{
|
||||
"id": "v24",
|
||||
"name": "V24",
|
||||
"nickname": "Air Horn",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 3243, "primary": True},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian channel marker known as 'Air Horn' due to distinctive foghorn-like sound. Continuous tone with occasional voice messages in Russian.",
|
||||
"operator": "Russian Military",
|
||||
"schedule": "24/7 continuous operation",
|
||||
"source_url": "https://priyom.org/military-stations/russia/the-air-horn"
|
||||
},
|
||||
{
|
||||
"id": "vc01",
|
||||
"name": "VC01",
|
||||
"nickname": "Chinese Robot",
|
||||
"type": "number",
|
||||
"country": "China",
|
||||
"country_code": "CN",
|
||||
"frequencies": [
|
||||
{"freq_khz": 8300, "primary": True},
|
||||
{"freq_khz": 9725, "primary": False},
|
||||
{"freq_khz": 11430, "primary": False},
|
||||
{"freq_khz": 13750, "primary": False},
|
||||
],
|
||||
"mode": "AM",
|
||||
"description": "Chinese intelligence number station. Robotic female voice reading 4-figure groups in Chinese. Distinctive electronic music interval signal.",
|
||||
"operator": "MSS (Chinese Intelligence)",
|
||||
"schedule": "Daily transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/chinese/vc01"
|
||||
},
|
||||
{
|
||||
"id": "v22",
|
||||
"name": "V22",
|
||||
"nickname": "Chinese Lady",
|
||||
"type": "number",
|
||||
"country": "China",
|
||||
"country_code": "CN",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7883, "primary": True},
|
||||
{"freq_khz": 9170, "primary": False},
|
||||
],
|
||||
"mode": "AM",
|
||||
"description": "Chinese number station using female voice. Reads 4-figure groups in Mandarin Chinese. Often reported in Southeast Asian target areas.",
|
||||
"operator": "MSS (Chinese Intelligence)",
|
||||
"schedule": "Evening transmissions UTC",
|
||||
"source_url": "https://priyom.org/number-stations/chinese/v22"
|
||||
},
|
||||
# Diplomatic Stations
|
||||
{
|
||||
"id": "bulgaria_mfa",
|
||||
"name": "Bulgaria MFA",
|
||||
"nickname": "Sofia Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Bulgaria",
|
||||
"country_code": "BG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5145, "primary": True},
|
||||
{"freq_khz": 6755, "primary": False},
|
||||
{"freq_khz": 7670, "primary": False},
|
||||
{"freq_khz": 9155, "primary": False},
|
||||
{"freq_khz": 10175, "primary": False},
|
||||
{"freq_khz": 11445, "primary": False},
|
||||
{"freq_khz": 14725, "primary": False},
|
||||
{"freq_khz": 18520, "primary": False},
|
||||
],
|
||||
"mode": "RFSM-8000/MIL-STD-188-110",
|
||||
"description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.",
|
||||
"operator": "Bulgarian MFA",
|
||||
"schedule": "Daily scheduled transmissions",
|
||||
"source_url": "https://priyom.org/diplomatic/bulgaria"
|
||||
},
|
||||
{
|
||||
"id": "czechia_mfa",
|
||||
"name": "Czechia MFA",
|
||||
"nickname": "Czech Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Czechia",
|
||||
"country_code": "CZ",
|
||||
"frequencies": [
|
||||
{"freq_khz": 6830, "primary": True},
|
||||
{"freq_khz": 8130, "primary": False},
|
||||
{"freq_khz": 10232, "primary": False},
|
||||
{"freq_khz": 13890, "primary": False},
|
||||
],
|
||||
"mode": "PACTOR-III",
|
||||
"description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.",
|
||||
"operator": "Czech MFA / MoD",
|
||||
"schedule": "Regular scheduled traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/czechia"
|
||||
},
|
||||
{
|
||||
"id": "egypt_mfa",
|
||||
"name": "Egypt MFA",
|
||||
"nickname": "Egyptian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Egypt",
|
||||
"country_code": "EG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7830, "primary": True},
|
||||
{"freq_khz": 9048, "primary": False},
|
||||
{"freq_khz": 10780, "primary": False},
|
||||
{"freq_khz": 13950, "primary": False},
|
||||
],
|
||||
"mode": "SITOR/Codan 3012",
|
||||
"description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.",
|
||||
"operator": "Egyptian MFA",
|
||||
"schedule": "Daily traffic windows",
|
||||
"source_url": "https://priyom.org/diplomatic/egypt"
|
||||
},
|
||||
{
|
||||
"id": "dprk_mfa",
|
||||
"name": "DPRK MFA",
|
||||
"nickname": "North Korea Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "North Korea",
|
||||
"country_code": "KP",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7200, "primary": True},
|
||||
{"freq_khz": 9450, "primary": False},
|
||||
{"freq_khz": 11475, "primary": False},
|
||||
{"freq_khz": 13785, "primary": False},
|
||||
{"freq_khz": 15245, "primary": False},
|
||||
{"freq_khz": 17550, "primary": False},
|
||||
{"freq_khz": 21680, "primary": False},
|
||||
{"freq_khz": 25120, "primary": False},
|
||||
],
|
||||
"mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)",
|
||||
"description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.",
|
||||
"operator": "DPRK MFA",
|
||||
"schedule": "Daily, multiple time slots",
|
||||
"source_url": "https://priyom.org/diplomatic/north-korea"
|
||||
},
|
||||
{
|
||||
"id": "russia_mfa",
|
||||
"name": "Russia MFA",
|
||||
"nickname": "Russian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5154, "primary": True},
|
||||
{"freq_khz": 7654, "primary": False},
|
||||
{"freq_khz": 9045, "primary": False},
|
||||
{"freq_khz": 10755, "primary": False},
|
||||
{"freq_khz": 13455, "primary": False},
|
||||
{"freq_khz": 16354, "primary": False},
|
||||
{"freq_khz": 18954, "primary": False},
|
||||
],
|
||||
"mode": "Perelivt/Serdolik/X06/OFDM",
|
||||
"description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.",
|
||||
"operator": "Russian MFA",
|
||||
"schedule": "24/7 network operations",
|
||||
"source_url": "https://priyom.org/diplomatic/russia"
|
||||
},
|
||||
{
|
||||
"id": "tunisia_mfa",
|
||||
"name": "Tunisia MFA",
|
||||
"nickname": "Tunisian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Tunisia",
|
||||
"country_code": "TN",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5810, "primary": True},
|
||||
{"freq_khz": 7954, "primary": False},
|
||||
{"freq_khz": 8014, "primary": False},
|
||||
{"freq_khz": 8180, "primary": False},
|
||||
{"freq_khz": 10113, "primary": False},
|
||||
{"freq_khz": 10176, "primary": False},
|
||||
{"freq_khz": 11111, "primary": False},
|
||||
{"freq_khz": 12140, "primary": False},
|
||||
{"freq_khz": 13945, "primary": False},
|
||||
{"freq_khz": 14700, "primary": False},
|
||||
{"freq_khz": 14724, "primary": False},
|
||||
{"freq_khz": 15635, "primary": False},
|
||||
{"freq_khz": 16125, "primary": False},
|
||||
{"freq_khz": 16285, "primary": False},
|
||||
{"freq_khz": 16290, "primary": False},
|
||||
{"freq_khz": 18295, "primary": False},
|
||||
{"freq_khz": 19675, "primary": False},
|
||||
{"freq_khz": 23540, "primary": False},
|
||||
{"freq_khz": 24080, "primary": False},
|
||||
{"freq_khz": 24170, "primary": False},
|
||||
{"freq_khz": 26890, "primary": False},
|
||||
],
|
||||
"mode": "2G ALE/PACTOR-II",
|
||||
"description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.",
|
||||
"operator": "Tunisian MFA",
|
||||
"schedule": "Regular diplomatic traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/tunisia"
|
||||
},
|
||||
{
|
||||
"id": "usa_state",
|
||||
"name": "US State Dept",
|
||||
"nickname": "American Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "United States",
|
||||
"country_code": "US",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5749, "primary": True},
|
||||
{"freq_khz": 6903, "primary": False},
|
||||
{"freq_khz": 8059, "primary": False},
|
||||
{"freq_khz": 10734, "primary": False},
|
||||
{"freq_khz": 11169, "primary": False},
|
||||
{"freq_khz": 13504, "primary": False},
|
||||
{"freq_khz": 16284, "primary": False},
|
||||
{"freq_khz": 18249, "primary": False},
|
||||
{"freq_khz": 20811, "primary": False},
|
||||
{"freq_khz": 24884, "primary": False},
|
||||
],
|
||||
"mode": "2G ALE (MIL-STD-188-141A)",
|
||||
"description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.",
|
||||
"operator": "US State Department",
|
||||
"schedule": "24/7 global network",
|
||||
"source_url": "https://priyom.org/diplomatic/united-states"
|
||||
},
|
||||
{
|
||||
"id": "morocco_mfa",
|
||||
"name": "Morocco MFA",
|
||||
"nickname": "Moroccan Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Morocco",
|
||||
"country_code": "MA",
|
||||
"frequencies": [
|
||||
{"freq_khz": 8010, "primary": True},
|
||||
{"freq_khz": 11205, "primary": False},
|
||||
{"freq_khz": 14620, "primary": False},
|
||||
],
|
||||
"mode": "PACTOR-II/ALE",
|
||||
"description": "Moroccan Ministry of Foreign Affairs diplomatic network. Links Rabat with embassies in Europe and Africa. Uses PACTOR-II and 2G ALE.",
|
||||
"operator": "Moroccan MFA",
|
||||
"schedule": "Daily scheduled traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/morocco"
|
||||
},
|
||||
{
|
||||
"id": "poland_mfa",
|
||||
"name": "Poland MFA",
|
||||
"nickname": "Polish Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Poland",
|
||||
"country_code": "PL",
|
||||
"frequencies": [
|
||||
{"freq_khz": 6825, "primary": True},
|
||||
{"freq_khz": 9250, "primary": False},
|
||||
{"freq_khz": 13485, "primary": False},
|
||||
],
|
||||
"mode": "STANAG-4285/ALE",
|
||||
"description": "Polish Ministry of Foreign Affairs HF network. Uses NATO STANAG-4285 modem with 2G ALE linking. Connects Warsaw with global embassies.",
|
||||
"operator": "Polish MFA",
|
||||
"schedule": "Regular diplomatic traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/poland"
|
||||
},
|
||||
{
|
||||
"id": "france_mfa",
|
||||
"name": "France MFA",
|
||||
"nickname": "French Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "France",
|
||||
"country_code": "FR",
|
||||
"frequencies": [
|
||||
{"freq_khz": 6910, "primary": True},
|
||||
{"freq_khz": 10640, "primary": False},
|
||||
{"freq_khz": 13870, "primary": False},
|
||||
{"freq_khz": 16840, "primary": False},
|
||||
],
|
||||
"mode": "MIL-STD-188-110/ALE",
|
||||
"description": "French Ministry of Foreign Affairs network. Extensive global coverage with Paris hub. Uses MIL-STD-188-110 with 2G/3G ALE linking protocols.",
|
||||
"operator": "French MFA",
|
||||
"schedule": "24/7 network operations",
|
||||
"source_url": "https://priyom.org/diplomatic/france"
|
||||
},
|
||||
{
|
||||
"id": "romania_mfa",
|
||||
"name": "Romania MFA",
|
||||
"nickname": "Romanian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Romania",
|
||||
"country_code": "RO",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5390, "primary": True},
|
||||
{"freq_khz": 8158, "primary": False},
|
||||
{"freq_khz": 11555, "primary": False},
|
||||
],
|
||||
"mode": "PACTOR-III/ALE",
|
||||
"description": "Romanian diplomatic network linking Bucharest with embassies. Uses PACTOR-III for traffic and 2G ALE for channel establishment.",
|
||||
"operator": "Romanian MFA",
|
||||
"schedule": "Scheduled daily windows",
|
||||
"source_url": "https://priyom.org/diplomatic/romania"
|
||||
},
|
||||
{
|
||||
"id": "algeria_mfa",
|
||||
"name": "Algeria MFA",
|
||||
"nickname": "Algerian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Algeria",
|
||||
"country_code": "DZ",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7706, "primary": True},
|
||||
{"freq_khz": 10235, "primary": False},
|
||||
{"freq_khz": 14385, "primary": False},
|
||||
],
|
||||
"mode": "SITOR-B/PACTOR",
|
||||
"description": "Algerian Ministry of Foreign Affairs network. Links Algiers with African and European embassies. Uses SITOR-B and PACTOR modes.",
|
||||
"operator": "Algerian MFA",
|
||||
"schedule": "Daily scheduled transmissions",
|
||||
"source_url": "https://priyom.org/diplomatic/algeria"
|
||||
},
|
||||
{
|
||||
"id": "egypt_mfa_m14a",
|
||||
"name": "Egypt MFA M14a",
|
||||
"nickname": "Egyptian Extended",
|
||||
"type": "diplomatic",
|
||||
"country": "Egypt",
|
||||
"country_code": "EG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 12175, "primary": True},
|
||||
{"freq_khz": 16360, "primary": False},
|
||||
],
|
||||
"mode": "Codan 3012/SITOR",
|
||||
"description": "Extended Egyptian diplomatic network frequencies. Higher frequency allocations for long-distance embassy communications to Asia and Americas.",
|
||||
"operator": "Egyptian MFA",
|
||||
"schedule": "Daily traffic windows",
|
||||
"source_url": "https://priyom.org/diplomatic/egypt"
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@spy_stations_bp.route('/stations')
|
||||
def get_stations():
|
||||
"""Return all spy stations, optionally filtered."""
|
||||
station_type = request.args.get('type')
|
||||
country = request.args.get('country')
|
||||
mode = request.args.get('mode')
|
||||
|
||||
filtered = STATIONS
|
||||
|
||||
if station_type:
|
||||
filtered = [s for s in filtered if s['type'] == station_type]
|
||||
|
||||
if country:
|
||||
filtered = [s for s in filtered if s['country_code'].upper() == country.upper()]
|
||||
|
||||
if mode:
|
||||
mode_lower = mode.lower()
|
||||
filtered = [s for s in filtered if mode_lower in s['mode'].lower()]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'count': len(filtered),
|
||||
'stations': filtered
|
||||
})
|
||||
|
||||
|
||||
@spy_stations_bp.route('/stations/<station_id>')
|
||||
def get_station(station_id):
|
||||
"""Get a single station by ID."""
|
||||
for station in STATIONS:
|
||||
if station['id'] == station_id:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'station': station
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Station not found'
|
||||
}), 404
|
||||
|
||||
|
||||
@spy_stations_bp.route('/filters')
|
||||
def get_filters():
|
||||
"""Return available filter options."""
|
||||
types = list(set(s['type'] for s in STATIONS))
|
||||
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
|
||||
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'filters': {
|
||||
'types': types,
|
||||
'countries': [{'name': c[0], 'code': c[1]} for c in countries],
|
||||
'modes': modes
|
||||
}
|
||||
})
|
||||
@@ -1098,3 +1098,318 @@ def stream_wifi():
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# V2 API Endpoints - Using unified WiFi scanner
|
||||
# =============================================================================
|
||||
|
||||
from utils.wifi.scanner import get_wifi_scanner, reset_wifi_scanner
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/capabilities')
|
||||
def get_v2_capabilities():
|
||||
"""Get WiFi scanning capabilities on this system."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
caps = scanner.check_capabilities()
|
||||
return jsonify({
|
||||
'platform': caps.platform,
|
||||
'is_root': caps.is_root,
|
||||
'can_quick_scan': caps.can_quick_scan,
|
||||
'can_deep_scan': caps.can_deep_scan,
|
||||
'preferred_quick_tool': caps.preferred_quick_tool,
|
||||
'interfaces': caps.interfaces,
|
||||
'default_interface': caps.default_interface,
|
||||
'has_monitor_capable_interface': caps.has_monitor_capable_interface,
|
||||
'monitor_interface': caps.monitor_interface,
|
||||
'issues': caps.issues,
|
||||
'tools': {
|
||||
'nmcli': caps.has_nmcli,
|
||||
'iw': caps.has_iw,
|
||||
'iwlist': caps.has_iwlist,
|
||||
'airport': caps.has_airport,
|
||||
'airmon_ng': caps.has_airmon_ng,
|
||||
'airodump_ng': caps.has_airodump_ng,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error checking capabilities")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/quick', methods=['POST'])
|
||||
def v2_quick_scan():
|
||||
"""Perform a quick one-shot WiFi scan using system tools."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
interface = data.get('interface')
|
||||
timeout = data.get('timeout', 10.0)
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
result = scanner.quick_scan(interface=interface, timeout=timeout)
|
||||
|
||||
if result.error:
|
||||
return jsonify({
|
||||
'error': result.error,
|
||||
'access_points': [],
|
||||
'channel_stats': [],
|
||||
'recommendations': [],
|
||||
}), 200 # Return 200 with error in body for cleaner handling
|
||||
|
||||
return jsonify({
|
||||
'access_points': [ap.to_summary_dict() for ap in result.access_points],
|
||||
'channel_stats': [s.to_dict() for s in result.channel_stats],
|
||||
'recommendations': [r.to_dict() for r in result.recommendations],
|
||||
'duration_seconds': result.duration_seconds,
|
||||
'warnings': result.warnings,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error in quick scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/start', methods=['POST'])
|
||||
def v2_start_scan():
|
||||
"""Start continuous deep scan with airodump-ng."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(interface=interface, band=band, channel=channel)
|
||||
|
||||
if success:
|
||||
return jsonify({'status': 'started'})
|
||||
else:
|
||||
status = scanner.get_status()
|
||||
return jsonify({'error': status.error or 'Failed to start scan'}), 400
|
||||
except Exception as e:
|
||||
logger.exception("Error starting deep scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/stop', methods=['POST'])
|
||||
def v2_stop_scan():
|
||||
"""Stop the current scan."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.stop_deep_scan()
|
||||
return jsonify({'status': 'stopped'})
|
||||
except Exception as e:
|
||||
logger.exception("Error stopping scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/status')
|
||||
def v2_scan_status():
|
||||
"""Get current scan status."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
status = scanner.get_status()
|
||||
return jsonify({
|
||||
'is_scanning': status.is_scanning,
|
||||
'scan_mode': status.scan_mode,
|
||||
'interface': status.interface,
|
||||
'started_at': status.started_at.isoformat() if status.started_at else None,
|
||||
'networks_found': status.networks_found,
|
||||
'clients_found': status.clients_found,
|
||||
'error': status.error,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting scan status")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/networks')
|
||||
def v2_get_networks():
|
||||
"""Get all discovered networks."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
networks = scanner.access_points
|
||||
return jsonify({
|
||||
'networks': [ap.to_summary_dict() for ap in networks],
|
||||
'total': len(networks),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting networks")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clients')
|
||||
def v2_get_clients():
|
||||
"""Get all discovered clients."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
return jsonify({
|
||||
'clients': [c.to_dict() for c in clients],
|
||||
'total': len(clients),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting clients")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/probes')
|
||||
def v2_get_probes():
|
||||
"""Get probe requests."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
probes = scanner.probe_requests
|
||||
return jsonify({
|
||||
'probes': [p.to_dict() for p in probes[-100:]], # Last 100
|
||||
'total': len(probes),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting probes")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/channels')
|
||||
def v2_get_channels():
|
||||
"""Get channel statistics and recommendations."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
stats = scanner._calculate_channel_stats()
|
||||
recommendations = scanner._generate_recommendations(stats)
|
||||
return jsonify({
|
||||
'channel_stats': [s.to_dict() for s in stats],
|
||||
'recommendations': [r.to_dict() for r in recommendations],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting channel stats")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/stream')
|
||||
def v2_stream():
|
||||
"""SSE stream for real-time WiFi events."""
|
||||
def generate():
|
||||
scanner = get_wifi_scanner()
|
||||
for event in scanner.get_event_stream():
|
||||
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
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/export')
|
||||
def v2_export():
|
||||
"""Export scan data as CSV or JSON."""
|
||||
try:
|
||||
format_type = request.args.get('format', 'json')
|
||||
data_type = request.args.get('type', 'all')
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
if format_type == 'json':
|
||||
data = {}
|
||||
if data_type in ('all', 'networks'):
|
||||
data['networks'] = [ap.to_summary_dict() for ap in scanner.access_points]
|
||||
if data_type in ('all', 'clients'):
|
||||
data['clients'] = [c.to_dict() for c in scanner.clients]
|
||||
if data_type in ('all', 'probes'):
|
||||
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
|
||||
|
||||
response = Response(
|
||||
json.dumps(data, indent=2, default=str),
|
||||
mimetype='application/json',
|
||||
)
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.json'
|
||||
return response
|
||||
|
||||
elif format_type == 'csv':
|
||||
import csv
|
||||
import io
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write networks
|
||||
writer.writerow(['Networks'])
|
||||
writer.writerow(['BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security', 'Vendor', 'Clients', 'First Seen', 'Last Seen'])
|
||||
for ap in scanner.access_points:
|
||||
writer.writerow([
|
||||
ap.bssid,
|
||||
ap.essid or '[Hidden]',
|
||||
ap.channel,
|
||||
ap.band,
|
||||
ap.rssi_current,
|
||||
ap.security,
|
||||
ap.vendor,
|
||||
ap.client_count,
|
||||
ap.first_seen.isoformat() if ap.first_seen else '',
|
||||
ap.last_seen.isoformat() if ap.last_seen else '',
|
||||
])
|
||||
|
||||
writer.writerow([])
|
||||
|
||||
# Write clients
|
||||
writer.writerow(['Clients'])
|
||||
writer.writerow(['MAC', 'BSSID', 'Vendor', 'RSSI', 'Probed SSIDs', 'First Seen', 'Last Seen'])
|
||||
for c in scanner.clients:
|
||||
writer.writerow([
|
||||
c.mac,
|
||||
c.associated_bssid or '',
|
||||
c.vendor,
|
||||
c.rssi_current,
|
||||
', '.join(c.probed_ssids),
|
||||
c.first_seen.isoformat() if c.first_seen else '',
|
||||
c.last_seen.isoformat() if c.last_seen else '',
|
||||
])
|
||||
|
||||
response = Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
)
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.csv'
|
||||
return response
|
||||
|
||||
else:
|
||||
return jsonify({'error': f'Unknown format: {format_type}'}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error exporting data")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/baseline/set', methods=['POST'])
|
||||
def v2_set_baseline():
|
||||
"""Set current networks as baseline."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.set_baseline()
|
||||
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
|
||||
except Exception as e:
|
||||
logger.exception("Error setting baseline")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/baseline/clear', methods=['POST'])
|
||||
def v2_clear_baseline():
|
||||
"""Clear the baseline."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_baseline()
|
||||
return jsonify({'status': 'baseline_cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing baseline")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clear', methods=['POST'])
|
||||
def v2_clear_data():
|
||||
"""Clear all discovered data."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_data()
|
||||
return jsonify({'status': 'cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing data")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
WiFi v2 API routes.
|
||||
|
||||
New unified WiFi scanning API with Quick Scan and Deep Scan modes,
|
||||
channel analysis, hidden SSID correlation, and SSE streaming.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
wifi_v2_bp = Blueprint('wifi_v2', __name__, url_prefix='/wifi/v2')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Capabilities
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/capabilities', methods=['GET'])
|
||||
def get_capabilities():
|
||||
"""
|
||||
Get WiFi scanning capabilities.
|
||||
|
||||
Returns available tools, interfaces, and scan mode support.
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
caps = scanner.check_capabilities()
|
||||
return jsonify(caps.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Quick Scan
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/scan/quick', methods=['POST'])
|
||||
def quick_scan():
|
||||
"""
|
||||
Perform a quick one-shot WiFi scan.
|
||||
|
||||
Uses system tools (nmcli, iw, iwlist, airport) without monitor mode.
|
||||
|
||||
Request body:
|
||||
interface: Optional interface name
|
||||
timeout: Optional scan timeout in seconds (default 15)
|
||||
|
||||
Returns:
|
||||
WiFiScanResult with discovered networks and channel analysis.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
timeout = float(data.get('timeout', 15))
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
result = scanner.quick_scan(interface=interface, timeout=timeout)
|
||||
|
||||
return jsonify(result.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Deep Scan (Monitor Mode)
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/scan/start', methods=['POST'])
|
||||
def start_deep_scan():
|
||||
"""
|
||||
Start a deep scan using airodump-ng.
|
||||
|
||||
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
|
||||
"""
|
||||
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
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'mode': SCAN_MODE_DEEP,
|
||||
'interface': interface or scanner._capabilities.monitor_interface,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': scanner._status.error,
|
||||
}), 400
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
||||
def stop_deep_scan():
|
||||
"""Stop the deep scan."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.stop_deep_scan()
|
||||
|
||||
return jsonify({
|
||||
'status': 'stopped',
|
||||
})
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/status', methods=['GET'])
|
||||
def get_scan_status():
|
||||
"""Get current scan status."""
|
||||
scanner = get_wifi_scanner()
|
||||
status = scanner.get_status()
|
||||
return jsonify(status.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/networks', methods=['GET'])
|
||||
def get_networks():
|
||||
"""
|
||||
Get all discovered networks.
|
||||
|
||||
Query params:
|
||||
band: Filter by band ('2.4GHz', '5GHz', '6GHz')
|
||||
security: Filter by security type ('Open', 'WEP', 'WPA', 'WPA2', 'WPA3')
|
||||
hidden: Filter hidden networks only (true/false)
|
||||
min_rssi: Minimum RSSI threshold
|
||||
sort: Sort field ('rssi', 'channel', 'essid', 'last_seen')
|
||||
order: Sort order ('asc', 'desc')
|
||||
format: Response format ('full', 'summary')
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
networks = scanner.access_points
|
||||
|
||||
# Apply filters
|
||||
band = request.args.get('band')
|
||||
if band:
|
||||
networks = [n for n in networks if n.band == band]
|
||||
|
||||
security = request.args.get('security')
|
||||
if security:
|
||||
networks = [n for n in networks if n.security == security]
|
||||
|
||||
hidden = request.args.get('hidden')
|
||||
if hidden == 'true':
|
||||
networks = [n for n in networks if n.is_hidden]
|
||||
elif hidden == 'false':
|
||||
networks = [n for n in networks if not n.is_hidden]
|
||||
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
networks = [n for n in networks if n.rssi_current and n.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Apply sorting
|
||||
sort_field = request.args.get('sort', 'rssi')
|
||||
order = request.args.get('order', 'desc')
|
||||
reverse = order == 'desc'
|
||||
|
||||
sort_key_map = {
|
||||
'rssi': lambda n: n.rssi_current or -100,
|
||||
'channel': lambda n: n.channel or 0,
|
||||
'essid': lambda n: (n.essid or '').lower(),
|
||||
'last_seen': lambda n: n.last_seen,
|
||||
'clients': lambda n: n.client_count,
|
||||
}
|
||||
|
||||
if sort_field in sort_key_map:
|
||||
networks.sort(key=sort_key_map[sort_field], reverse=reverse)
|
||||
|
||||
# Format output
|
||||
output_format = request.args.get('format', 'summary')
|
||||
if output_format == 'full':
|
||||
return jsonify([n.to_dict() for n in networks])
|
||||
else:
|
||||
return jsonify([n.to_summary_dict() for n in networks])
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/networks/<bssid>', methods=['GET'])
|
||||
def get_network(bssid):
|
||||
"""Get a specific network by BSSID."""
|
||||
scanner = get_wifi_scanner()
|
||||
network = scanner.get_network(bssid)
|
||||
|
||||
if network:
|
||||
return jsonify(network.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Network not found'}), 404
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients', methods=['GET'])
|
||||
def get_clients():
|
||||
"""
|
||||
Get all discovered clients.
|
||||
|
||||
Query params:
|
||||
associated: Filter by association status (true/false)
|
||||
bssid: Filter by associated BSSID
|
||||
min_rssi: Minimum RSSI threshold
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
|
||||
# Apply filters
|
||||
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]
|
||||
|
||||
bssid = request.args.get('bssid')
|
||||
if bssid:
|
||||
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
|
||||
|
||||
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([c.to_dict() for c in clients])
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients/<mac>', methods=['GET'])
|
||||
def get_client(mac):
|
||||
"""Get a specific client by MAC address."""
|
||||
scanner = get_wifi_scanner()
|
||||
client = scanner.get_client(mac)
|
||||
|
||||
if client:
|
||||
return jsonify(client.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Client not found'}), 404
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/probes', methods=['GET'])
|
||||
def get_probes():
|
||||
"""
|
||||
Get captured probe requests.
|
||||
|
||||
Query params:
|
||||
client_mac: Filter by client MAC
|
||||
ssid: Filter by probed SSID
|
||||
limit: Maximum number of results
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
probes = scanner.probe_requests
|
||||
|
||||
# Apply filters
|
||||
client_mac = request.args.get('client_mac')
|
||||
if client_mac:
|
||||
probes = [p for p in probes if p.client_mac == client_mac.upper()]
|
||||
|
||||
ssid = request.args.get('ssid')
|
||||
if ssid:
|
||||
probes = [p for p in probes if p.probed_ssid == ssid]
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit')
|
||||
if limit:
|
||||
try:
|
||||
limit = int(limit)
|
||||
probes = probes[-limit:] # Most recent
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify([p.to_dict() for p in probes])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channel Analysis
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/channels', methods=['GET'])
|
||||
def get_channel_stats():
|
||||
"""
|
||||
Get channel utilization statistics and recommendations.
|
||||
|
||||
Query params:
|
||||
include_dfs: Include DFS channels in recommendations (true/false)
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
include_dfs = request.args.get('include_dfs', 'false') == 'true'
|
||||
|
||||
stats, recommendations = analyze_channels(
|
||||
scanner.access_points,
|
||||
include_dfs=include_dfs,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'stats': [s.to_dict() for s in stats],
|
||||
'recommendations': [r.to_dict() for r in recommendations],
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hidden SSID Correlation
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/hidden', methods=['GET'])
|
||||
def get_hidden_correlations():
|
||||
"""
|
||||
Get revealed hidden SSIDs from correlation.
|
||||
|
||||
Returns mapping of BSSID -> revealed SSID.
|
||||
"""
|
||||
correlator = get_hidden_correlator()
|
||||
return jsonify(correlator.get_all_revealed())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Baseline Management
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/baseline/set', methods=['POST'])
|
||||
def set_baseline():
|
||||
"""Mark current networks as baseline (known networks)."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.set_baseline()
|
||||
|
||||
return jsonify({
|
||||
'status': 'baseline_set',
|
||||
'network_count': len(scanner._baseline_networks),
|
||||
'set_at': datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/baseline/clear', methods=['POST'])
|
||||
def clear_baseline():
|
||||
"""Clear the baseline."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_baseline()
|
||||
|
||||
return jsonify({
|
||||
'status': 'baseline_cleared',
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SSE Streaming
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/stream', methods=['GET'])
|
||||
def event_stream():
|
||||
"""
|
||||
Server-Sent Events stream for real-time updates.
|
||||
|
||||
Events:
|
||||
- network_update: Network discovered/updated
|
||||
- client_update: Client discovered/updated
|
||||
- probe_request: Probe request detected
|
||||
- hidden_revealed: Hidden SSID revealed
|
||||
- scan_started, scan_stopped, scan_error
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Management
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/clear', methods=['POST'])
|
||||
def clear_data():
|
||||
"""Clear all discovered data."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_data()
|
||||
|
||||
return jsonify({
|
||||
'status': 'cleared',
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Export
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/export', methods=['GET'])
|
||||
def export_data():
|
||||
"""
|
||||
Export scan data.
|
||||
|
||||
Query params:
|
||||
format: 'json' or 'csv' (default: json)
|
||||
type: 'networks', 'clients', 'probes', 'all' (default: all)
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
export_format = request.args.get('format', 'json')
|
||||
export_type = request.args.get('type', 'all')
|
||||
|
||||
if export_format == 'csv':
|
||||
return _export_csv(scanner, export_type)
|
||||
else:
|
||||
return _export_json(scanner, export_type)
|
||||
|
||||
|
||||
def _export_json(scanner, export_type: str) -> Response:
|
||||
"""Export data as JSON."""
|
||||
data = {}
|
||||
|
||||
if export_type in ('networks', 'all'):
|
||||
data['networks'] = [n.to_dict() for n in scanner.access_points]
|
||||
|
||||
if export_type in ('clients', 'all'):
|
||||
data['clients'] = [c.to_dict() for c in scanner.clients]
|
||||
|
||||
if export_type in ('probes', 'all'):
|
||||
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
|
||||
|
||||
data['exported_at'] = datetime.now().isoformat()
|
||||
data['network_count'] = len(scanner.access_points)
|
||||
data['client_count'] = len(scanner.clients)
|
||||
|
||||
response = Response(
|
||||
json.dumps(data, indent=2),
|
||||
mimetype='application/json',
|
||||
)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
return response
|
||||
|
||||
|
||||
def _export_csv(scanner, export_type: str) -> Response:
|
||||
"""Export data as CSV."""
|
||||
output = io.StringIO()
|
||||
|
||||
if export_type in ('networks', 'all'):
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
'BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security',
|
||||
'Cipher', 'Auth', 'Vendor', 'Clients', 'First Seen', 'Last Seen'
|
||||
])
|
||||
|
||||
for n in scanner.access_points:
|
||||
writer.writerow([
|
||||
n.bssid,
|
||||
n.essid or '[Hidden]',
|
||||
n.channel,
|
||||
n.band,
|
||||
n.rssi_current,
|
||||
n.security,
|
||||
n.cipher,
|
||||
n.auth,
|
||||
n.vendor or '',
|
||||
n.client_count,
|
||||
n.first_seen.isoformat(),
|
||||
n.last_seen.isoformat(),
|
||||
])
|
||||
|
||||
if export_type == 'all':
|
||||
writer.writerow([]) # Blank line separator
|
||||
|
||||
if export_type in ('clients', 'all'):
|
||||
writer = csv.writer(output)
|
||||
if export_type == 'clients':
|
||||
writer.writerow([
|
||||
'MAC', 'Vendor', 'RSSI', 'Associated BSSID', 'Probed SSIDs',
|
||||
'First Seen', 'Last Seen'
|
||||
])
|
||||
|
||||
for c in scanner.clients:
|
||||
writer.writerow([
|
||||
c.mac,
|
||||
c.vendor or '',
|
||||
c.rssi_current,
|
||||
c.associated_bssid or '',
|
||||
', '.join(c.probed_ssids),
|
||||
c.first_seen.isoformat(),
|
||||
c.last_seen.isoformat(),
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
return response
|
||||
@@ -69,6 +69,18 @@ echo
|
||||
# ----------------------------
|
||||
# Helpers
|
||||
# ----------------------------
|
||||
NON_INTERACTIVE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--non-interactive)
|
||||
NON_INTERACTIVE=true
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cmd_exists() {
|
||||
local c="$1"
|
||||
command -v "$c" >/dev/null 2>&1 && return 0
|
||||
@@ -76,6 +88,32 @@ cmd_exists() {
|
||||
return 1
|
||||
}
|
||||
|
||||
ask_yes_no() {
|
||||
local prompt="$1"
|
||||
local default="${2:-n}" # default to no for safety
|
||||
local response
|
||||
|
||||
if $NON_INTERACTIVE; then
|
||||
info "Non-interactive mode: defaulting to ${default} for prompt: ${prompt}"
|
||||
[[ "$default" == "y" ]]
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
warn "No TTY available for prompt: ${prompt}"
|
||||
[[ "$default" == "y" ]]
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$default" == "y" ]]; then
|
||||
read -r -p "$prompt [Y/n]: " response
|
||||
[[ -z "$response" || "$response" =~ ^[Yy] ]]
|
||||
else
|
||||
read -r -p "$prompt [y/N]: " response
|
||||
[[ "$response" =~ ^[Yy] ]]
|
||||
fi
|
||||
}
|
||||
|
||||
have_any() {
|
||||
local c
|
||||
for c in "$@"; do
|
||||
@@ -111,6 +149,18 @@ detect_os() {
|
||||
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
|
||||
}
|
||||
|
||||
detect_dragonos() {
|
||||
IS_DRAGONOS=false
|
||||
# Check for DragonOS markers
|
||||
if [[ -f /etc/dragonos-release ]] || \
|
||||
[[ -d /usr/share/dragonos ]] || \
|
||||
grep -qi "dragonos" /etc/os-release 2>/dev/null; then
|
||||
IS_DRAGONOS=true
|
||||
warn "DragonOS detected! This distro has many tools pre-installed."
|
||||
warn "The script will prompt before making system changes."
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# Required tool checks (with alternates)
|
||||
# ----------------------------
|
||||
@@ -128,6 +178,17 @@ check_required() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_optional() {
|
||||
local label="$1"; shift
|
||||
local desc="$1"; shift
|
||||
|
||||
if have_any "$@"; then
|
||||
ok "${label} - ${desc}"
|
||||
else
|
||||
warn "${label} - ${desc} (missing, optional)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_tools() {
|
||||
info "Checking required tools..."
|
||||
missing_required=()
|
||||
@@ -136,9 +197,13 @@ check_tools() {
|
||||
info "Core SDR:"
|
||||
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
|
||||
check_required "rtl_test" "RTL-SDR device detection" rtl_test
|
||||
check_required "rtl_tcp" "RTL-SDR TCP server" rtl_tcp
|
||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
|
||||
echo
|
||||
info "GPS:"
|
||||
@@ -229,9 +294,9 @@ install_python_deps() {
|
||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
||||
# Verify critical packages are available
|
||||
python -c "import flask; import requests" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests) not installed"
|
||||
echo "Try: sudo apt install python3-flask python3-requests"
|
||||
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||
echo "Try: pip install flask requests flask-limiter"
|
||||
exit 1
|
||||
}
|
||||
ok "Core Python dependencies available"
|
||||
@@ -265,12 +330,90 @@ brew_install() {
|
||||
return 0
|
||||
fi
|
||||
info "brew: installing ${pkg}..."
|
||||
brew install "$pkg"
|
||||
ok "brew: installed ${pkg}"
|
||||
if brew install "$pkg" 2>&1; then
|
||||
ok "brew: installed ${pkg}"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_rtlamr_from_source() {
|
||||
info "Installing rtlamr from source (requires Go)..."
|
||||
|
||||
# Check if Go is installed, install if needed
|
||||
if ! cmd_exists go; then
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
info "Installing Go via Homebrew..."
|
||||
brew_install go || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
|
||||
else
|
||||
info "Installing Go via apt..."
|
||||
$SUDO apt-get install -y golang >/dev/null 2>&1 || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set up Go environment
|
||||
export GOPATH="${GOPATH:-$HOME/go}"
|
||||
export PATH="$GOPATH/bin:$PATH"
|
||||
mkdir -p "$GOPATH/bin"
|
||||
|
||||
info "Building rtlamr..."
|
||||
if go install github.com/bemasher/rtlamr@latest 2>/dev/null; then
|
||||
# Link to system path
|
||||
if [[ -f "$GOPATH/bin/rtlamr" ]]; then
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
else
|
||||
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
fi
|
||||
else
|
||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||
fi
|
||||
ok "rtlamr installed successfully"
|
||||
else
|
||||
warn "rtlamr binary not found after build"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
warn "Failed to build rtlamr"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_multimon_ng_from_source_macos() {
|
||||
info "multimon-ng not available via Homebrew. Building from source..."
|
||||
|
||||
# Ensure build dependencies are installed
|
||||
brew_install cmake
|
||||
brew_install libsndfile
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning multimon-ng..."
|
||||
git clone --depth 1 https://github.com/EliasOenal/multimon-ng.git "$tmp_dir/multimon-ng" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone multimon-ng"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/multimon-ng"
|
||||
info "Compiling multimon-ng..."
|
||||
mkdir -p build && cd build
|
||||
cmake .. >/dev/null 2>&1 || { fail "cmake failed for multimon-ng"; exit 1; }
|
||||
make >/dev/null 2>&1 || { fail "make failed for multimon-ng"; exit 1; }
|
||||
|
||||
# Install to /usr/local/bin (no sudo needed on Homebrew systems typically)
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
else
|
||||
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||
fi
|
||||
ok "multimon-ng installed successfully from source"
|
||||
)
|
||||
}
|
||||
|
||||
install_macos_packages() {
|
||||
TOTAL_STEPS=12
|
||||
TOTAL_STEPS=15
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -280,7 +423,15 @@ install_macos_packages() {
|
||||
brew_install librtlsdr
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
brew_install multimon-ng
|
||||
# multimon-ng is not in Homebrew core, so build from source
|
||||
if ! cmd_exists multimon-ng; then
|
||||
install_multimon_ng_from_source_macos
|
||||
else
|
||||
ok "multimon-ng already installed"
|
||||
fi
|
||||
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
brew_install ffmpeg
|
||||
@@ -288,9 +439,33 @@ install_macos_packages() {
|
||||
progress "Installing rtl_433"
|
||||
brew_install rtl_433
|
||||
|
||||
progress "Installing rtlamr (optional)"
|
||||
# rtlamr is optional - used for utility meter monitoring
|
||||
if ! cmd_exists rtlamr; then
|
||||
echo
|
||||
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
|
||||
if ask_yes_no "Do you want to install rtlamr?"; then
|
||||
install_rtlamr_from_source
|
||||
else
|
||||
warn "Skipping rtlamr installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "rtlamr already installed"
|
||||
fi
|
||||
|
||||
progress "Installing dump1090"
|
||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||
|
||||
progress "Installing acarsdec"
|
||||
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
|
||||
else
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
brew_install aircrack-ng
|
||||
|
||||
@@ -303,7 +478,21 @@ install_macos_packages() {
|
||||
progress "Installing gpsd"
|
||||
brew_install gpsd
|
||||
|
||||
progress "Installing Ubertooth tools (optional)"
|
||||
if ! cmd_exists ubertooth-btle; then
|
||||
echo
|
||||
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||
brew_install ubertooth || warn "Ubertooth not available via Homebrew"
|
||||
else
|
||||
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "Ubertooth already installed"
|
||||
fi
|
||||
|
||||
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -334,6 +523,15 @@ apt_try_install_any() {
|
||||
return 1
|
||||
}
|
||||
|
||||
apt_install_if_missing() {
|
||||
local pkg="$1"
|
||||
if dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
|
||||
ok "apt: ${pkg} already installed"
|
||||
return 0
|
||||
fi
|
||||
apt_install "$pkg"
|
||||
}
|
||||
|
||||
install_dump1090_from_source_debian() {
|
||||
info "dump1090 not available via APT. Building from source (required)..."
|
||||
|
||||
@@ -372,6 +570,137 @@ install_dump1090_from_source_debian() {
|
||||
)
|
||||
}
|
||||
|
||||
install_acarsdec_from_source_debian() {
|
||||
info "acarsdec not available via APT. Building from source..."
|
||||
|
||||
apt_install build-essential git cmake \
|
||||
librtlsdr-dev libusb-1.0-0-dev libsndfile1-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
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
|
||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
ok "acarsdec installed successfully."
|
||||
else
|
||||
warn "Failed to build acarsdec from source. ACARS decoding will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_aiscatcher_from_source_debian() {
|
||||
info "AIS-catcher not available via APT. Building from source..."
|
||||
|
||||
apt_install build-essential git cmake pkg-config \
|
||||
librtlsdr-dev libusb-1.0-0-dev libcurl4-openssl-dev zlib1g-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
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
|
||||
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||
ok "AIS-catcher installed successfully."
|
||||
else
|
||||
warn "Failed to build AIS-catcher from source. AIS vessel tracking will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_ubertooth_from_source_debian() {
|
||||
info "Building Ubertooth from source..."
|
||||
|
||||
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config libbluetooth-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning Ubertooth..."
|
||||
git clone --depth 1 https://github.com/greatscottgadgets/ubertooth.git "$tmp_dir/ubertooth" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone Ubertooth"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/ubertooth/host"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling Ubertooth..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig
|
||||
ok "Ubertooth installed successfully from source."
|
||||
else
|
||||
warn "Failed to build Ubertooth from source."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_rtlsdr_blog_drivers_debian() {
|
||||
# The RTL-SDR Blog drivers provide better support for:
|
||||
# - RTL-SDR Blog V4 (R828D tuner)
|
||||
# - RTL-SDR Blog V3 with bias-t improvements
|
||||
# - Better overall compatibility with all RTL-SDR devices
|
||||
# These drivers are backward compatible with standard RTL-SDR devices.
|
||||
|
||||
info "Installing RTL-SDR Blog drivers (improved V4 support)..."
|
||||
|
||||
# Install build dependencies
|
||||
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning RTL-SDR Blog driver fork..."
|
||||
git clone https://github.com/rtlsdrblog/rtl-sdr-blog.git "$tmp_dir/rtl-sdr-blog" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone RTL-SDR Blog drivers"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/rtl-sdr-blog"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling RTL-SDR Blog drivers..."
|
||||
if cmake .. -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig
|
||||
|
||||
# Copy udev rules if they exist
|
||||
if [[ -f ../rtl-sdr.rules ]]; then
|
||||
$SUDO cp ../rtl-sdr.rules /etc/udev/rules.d/20-rtlsdr-blog.rules
|
||||
$SUDO udevadm control --reload-rules || true
|
||||
$SUDO udevadm trigger || true
|
||||
fi
|
||||
|
||||
ok "RTL-SDR Blog drivers installed successfully."
|
||||
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
||||
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
||||
else
|
||||
warn "Failed to build RTL-SDR Blog drivers. Using stock drivers."
|
||||
warn "If you have an RTL-SDR Blog V4, you may need to install drivers manually."
|
||||
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
setup_udev_rules_debian() {
|
||||
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
||||
|
||||
@@ -389,31 +718,135 @@ EOF
|
||||
echo
|
||||
}
|
||||
|
||||
blacklist_kernel_drivers_debian() {
|
||||
local blacklist_file="/etc/modprobe.d/blacklist-rtlsdr.conf"
|
||||
|
||||
if [[ -f "$blacklist_file" ]]; then
|
||||
ok "RTL-SDR kernel driver blacklist already present"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Blacklisting conflicting DVB kernel drivers..."
|
||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
||||
blacklist dvb_usb_rtl28xxu
|
||||
blacklist rtl2832
|
||||
blacklist rtl2830
|
||||
blacklist r820t
|
||||
EOF
|
||||
|
||||
# Unload modules if currently loaded
|
||||
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
||||
if lsmod | grep -q "^$mod"; then
|
||||
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
||||
echo
|
||||
}
|
||||
|
||||
install_debian_packages() {
|
||||
need_sudo
|
||||
|
||||
# Suppress needrestart prompts (Ubuntu Server 22.04+)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
# Keep APT interactive when a TTY is available.
|
||||
if $NON_INTERACTIVE; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
elif [[ -t 0 ]]; then
|
||||
export DEBIAN_FRONTEND=readline
|
||||
export NEEDRESTART_MODE=a
|
||||
else
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=15
|
||||
TOTAL_STEPS=20
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
$SUDO apt-get update -y >/dev/null
|
||||
|
||||
progress "Installing RTL-SDR"
|
||||
apt_install rtl-sdr
|
||||
if ! $IS_DRAGONOS; then
|
||||
# Handle package conflict between librtlsdr0 and librtlsdr2
|
||||
# The newer librtlsdr0 (2.0.2) conflicts with older librtlsdr2 (2.0.1)
|
||||
if dpkg -l | grep -q "librtlsdr2"; then
|
||||
info "Detected librtlsdr2 conflict - upgrading to librtlsdr0..."
|
||||
|
||||
# Remove packages that depend on librtlsdr2, then remove librtlsdr2
|
||||
# These will be reinstalled with librtlsdr0 support
|
||||
$SUDO apt-get remove -y dump1090-mutability libgnuradio-osmosdr0.2.0t64 rtl-433 librtlsdr2 rtl-sdr 2>/dev/null || true
|
||||
$SUDO apt-get autoremove -y 2>/dev/null || true
|
||||
|
||||
ok "Removed conflicting librtlsdr2 packages"
|
||||
fi
|
||||
|
||||
# If rtl-sdr is in broken state, remove it completely first
|
||||
if dpkg -l | grep -q "^.[^i].*rtl-sdr" || ! dpkg -l rtl-sdr 2>/dev/null | grep -q "^ii"; then
|
||||
info "Removing broken rtl-sdr package..."
|
||||
$SUDO dpkg --remove --force-remove-reinstreq rtl-sdr 2>/dev/null || true
|
||||
$SUDO dpkg --purge --force-remove-reinstreq rtl-sdr 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Force remove librtlsdr2 if it still exists
|
||||
if dpkg -l | grep -q "librtlsdr2"; then
|
||||
info "Force removing librtlsdr2..."
|
||||
$SUDO dpkg --remove --force-all librtlsdr2 2>/dev/null || true
|
||||
$SUDO dpkg --purge --force-all librtlsdr2 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Clean up any partial installations
|
||||
$SUDO dpkg --configure -a 2>/dev/null || true
|
||||
$SUDO apt-get --fix-broken install -y 2>/dev/null || true
|
||||
fi
|
||||
|
||||
apt_install_if_missing rtl-sdr
|
||||
|
||||
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
|
||||
else
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
fi
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
apt_install multimon-ng
|
||||
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
apt_install direwolf || true
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
apt_install ffmpeg
|
||||
|
||||
progress "Installing rtl_433"
|
||||
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
||||
|
||||
progress "Installing rtlamr (optional)"
|
||||
# rtlamr is optional - used for utility meter monitoring
|
||||
if ! cmd_exists rtlamr; then
|
||||
echo
|
||||
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
|
||||
if ask_yes_no "Do you want to install rtlamr?"; then
|
||||
install_rtlamr_from_source
|
||||
else
|
||||
warn "Skipping rtlamr installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "rtlamr already installed"
|
||||
fi
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
apt_install aircrack-ng || true
|
||||
|
||||
@@ -426,8 +859,23 @@ install_debian_packages() {
|
||||
progress "Installing Bluetooth tools"
|
||||
apt_install bluez bluetooth || true
|
||||
|
||||
progress "Installing Ubertooth tools (optional)"
|
||||
if ! cmd_exists ubertooth-btle; then
|
||||
echo
|
||||
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||
apt_install libubertooth-dev ubertooth || install_ubertooth_from_source_debian
|
||||
else
|
||||
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "Ubertooth already installed"
|
||||
fi
|
||||
|
||||
progress "Installing SoapySDR"
|
||||
apt_install soapysdr-tools || true
|
||||
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
||||
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
||||
apt_install soapysdr-tools xtrx-dkms- || true
|
||||
|
||||
progress "Installing gpsd"
|
||||
apt_install gpsd gpsd-clients || true
|
||||
@@ -437,15 +885,50 @@ install_debian_packages() {
|
||||
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
||||
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
|
||||
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
|
||||
# bleak for BLE scanning with manufacturer data (TSCM mode)
|
||||
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
||||
|
||||
progress "Installing dump1090"
|
||||
if ! cmd_exists dump1090; then
|
||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||
fi
|
||||
if ! cmd_exists dump1090; then
|
||||
if cmd_exists dump1090-mutability; then
|
||||
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
||||
fi
|
||||
fi
|
||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||
|
||||
progress "Installing acarsdec"
|
||||
if ! cmd_exists acarsdec; then
|
||||
apt_install acarsdec || true
|
||||
fi
|
||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
install_aiscatcher_from_source_debian
|
||||
else
|
||||
ok "AIS-catcher 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)."
|
||||
else
|
||||
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
|
||||
blacklist_kernel_drivers_debian
|
||||
else
|
||||
warn "Skipped kernel driver blacklist. RTL-SDR may not work without manual config."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
@@ -455,26 +938,57 @@ final_summary_and_hard_fail() {
|
||||
check_tools
|
||||
|
||||
echo "============================================"
|
||||
echo
|
||||
echo "To start INTERCEPT:"
|
||||
echo " sudo -E venv/bin/python intercept.py"
|
||||
echo
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo
|
||||
echo "============================================"
|
||||
|
||||
if [[ "${#missing_required[@]}" -eq 0 ]]; then
|
||||
ok "All REQUIRED tools are installed."
|
||||
else
|
||||
fail "Missing REQUIRED tools:"
|
||||
for t in "${missing_required[@]}"; do echo " - $t"; done
|
||||
echo
|
||||
fail "Exiting because required tools are missing."
|
||||
echo
|
||||
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
|
||||
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
|
||||
exit 1
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
warn "macOS note: bluetoothctl/hcitool/hciconfig are Linux (BlueZ) tools and unavailable on macOS."
|
||||
warn "Bluetooth functionality will be limited. Other features should work."
|
||||
else
|
||||
fail "Exiting because required tools are missing."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# Pre-flight summary
|
||||
# ----------------------------
|
||||
show_install_summary() {
|
||||
info "Installation Summary:"
|
||||
echo
|
||||
echo "To start INTERCEPT:"
|
||||
echo " source venv/bin/activate"
|
||||
echo " sudo python intercept.py"
|
||||
echo " OS: $OS"
|
||||
$IS_DRAGONOS && echo " DragonOS: Yes (safe mode enabled)"
|
||||
echo
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo " This script will:"
|
||||
echo " - Install missing SDR tools (rtl-sdr, multimon-ng, etc.)"
|
||||
echo " - Install Python dependencies in a virtual environment"
|
||||
echo
|
||||
if ! $IS_DRAGONOS; then
|
||||
echo " You will be prompted before:"
|
||||
echo " - Installing RTL-SDR Blog drivers (replaces existing)"
|
||||
echo " - Blacklisting kernel DVB drivers"
|
||||
fi
|
||||
echo
|
||||
if $NON_INTERACTIVE; then
|
||||
info "Non-interactive mode: continuing without prompt."
|
||||
return
|
||||
fi
|
||||
if ! ask_yes_no "Continue with installation?" "y"; then
|
||||
info "Installation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
@@ -482,6 +996,8 @@ final_summary_and_hard_fail() {
|
||||
# ----------------------------
|
||||
main() {
|
||||
detect_os
|
||||
detect_dragonos
|
||||
show_install_summary
|
||||
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
install_macos_packages
|
||||
@@ -494,4 +1010,3 @@ main() {
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.radar-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: -4px; }
|
||||
100% { top: 100vh; }
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.history-shell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 16px 18px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
background: linear-gradient(120deg, rgba(15, 18, 24, 0.95), rgba(20, 26, 36, 0.95));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 0 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.session-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
box-shadow: 0 0 12px rgba(75, 85, 99, 0.6);
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 14px rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
|
||||
.session-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.session-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#sessionNotice {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.session-controls select {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.primary-btn.stop {
|
||||
background: var(--accent-amber);
|
||||
color: #0a0c10;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 0 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.3px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.control-group input,
|
||||
.control-group select {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent-cyan);
|
||||
border: none;
|
||||
color: #0a0c10;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--accent-amber);
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 12px 14px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aircraft-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aircraft-table th,
|
||||
.aircraft-table td {
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px solid rgba(31, 41, 55, 0.6);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.aircraft-table th {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.aircraft-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.aircraft-row:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.empty-row td,
|
||||
.empty-row {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 18px 10px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#altitudeChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#speedChart,
|
||||
#headingChart,
|
||||
#verticalChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 18, 24, 0.6);
|
||||
}
|
||||
|
||||
.squawk-list {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 8, 15, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
width: min(820px, 92vw);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||
color: var(--accent-cyan);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-photo {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-fallback {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(20, 26, 36, 0.6);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||
}
|
||||
|
||||
.detail-row span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.detail-row strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-group input,
|
||||
.control-group select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* Activity Timeline Component
|
||||
* Reusable, configuration-driven timeline visualization
|
||||
* Supports visual modes: compact, enriched, summary
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS VARIABLES (with fallbacks)
|
||||
============================================ */
|
||||
.activity-timeline {
|
||||
--timeline-bg: var(--bg-card, #1a1a1a);
|
||||
--timeline-border: var(--border-color, #333);
|
||||
--timeline-bg-secondary: var(--bg-secondary, #252525);
|
||||
--timeline-bg-elevated: var(--bg-elevated, #2a2a2a);
|
||||
--timeline-text-primary: var(--text-primary, #fff);
|
||||
--timeline-text-secondary: var(--text-secondary, #888);
|
||||
--timeline-text-dim: var(--text-dim, #666);
|
||||
--timeline-accent: var(--accent-cyan, #4a9eff);
|
||||
--timeline-status-new: var(--signal-new, #3b82f6);
|
||||
--timeline-status-baseline: var(--signal-baseline, #6b7280);
|
||||
--timeline-status-burst: var(--signal-burst, #f59e0b);
|
||||
--timeline-status-flagged: var(--signal-emergency, #ef4444);
|
||||
--timeline-status-gone: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE CONTAINER
|
||||
============================================ */
|
||||
.activity-timeline {
|
||||
background: var(--timeline-bg);
|
||||
border: 1px solid var(--timeline-border);
|
||||
border-radius: 6px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.activity-timeline.collapsed .activity-timeline-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline.collapsed .activity-timeline-header {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.activity-timeline.collapsed .activity-timeline-collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER
|
||||
============================================ */
|
||||
.activity-timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.activity-timeline-header:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.activity-timeline-collapse-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--timeline-text-secondary);
|
||||
}
|
||||
|
||||
.activity-timeline-header-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-header-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-header-stat .stat-value {
|
||||
color: var(--timeline-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BODY
|
||||
============================================ */
|
||||
.activity-timeline-body {
|
||||
padding: 0 12px 12px 12px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CONTROLS
|
||||
============================================ */
|
||||
.activity-timeline-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.activity-timeline-btn {
|
||||
background: var(--timeline-bg-secondary);
|
||||
border: 1px solid var(--timeline-border);
|
||||
color: var(--timeline-text-secondary);
|
||||
font-size: 9px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.activity-timeline-btn:hover {
|
||||
background: var(--timeline-bg-elevated);
|
||||
color: var(--timeline-text-primary);
|
||||
}
|
||||
|
||||
.activity-timeline-btn.active {
|
||||
background: var(--timeline-accent);
|
||||
color: #000;
|
||||
border-color: var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-window {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.activity-timeline-window-select {
|
||||
background: var(--timeline-bg-secondary);
|
||||
border: 1px solid var(--timeline-border);
|
||||
color: var(--timeline-text-primary);
|
||||
font-size: 9px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIME AXIS
|
||||
============================================ */
|
||||
.activity-timeline-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 50px 0 140px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-axis-label {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.activity-timeline-axis-label::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--timeline-border);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LANES CONTAINER
|
||||
============================================ */
|
||||
.activity-timeline-lanes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar-track {
|
||||
background: var(--timeline-bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar-thumb {
|
||||
background: var(--timeline-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
INDIVIDUAL LANE
|
||||
============================================ */
|
||||
.activity-timeline-lane {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 32px;
|
||||
background: var(--timeline-bg-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.activity-timeline-lane:hover {
|
||||
background: var(--timeline-bg-elevated);
|
||||
}
|
||||
|
||||
.activity-timeline-lane.expanded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.activity-timeline-lane.baseline {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.activity-timeline-lane.baseline:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Status indicator strip */
|
||||
.activity-timeline-status {
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="new"] {
|
||||
background: var(--timeline-status-new);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="baseline"] {
|
||||
background: var(--timeline-status-baseline);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="burst"] {
|
||||
background: var(--timeline-status-burst);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="flagged"] {
|
||||
background: var(--timeline-status-flagged);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="gone"] {
|
||||
background: var(--timeline-status-gone);
|
||||
}
|
||||
|
||||
/* Label section */
|
||||
.activity-timeline-label {
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
border-right: 1px solid var(--timeline-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-timeline-id {
|
||||
color: var(--timeline-text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.activity-timeline-name {
|
||||
color: var(--timeline-text-dim);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TRACK (where bars are drawn)
|
||||
============================================ */
|
||||
.activity-timeline-track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.activity-timeline-track-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL BARS
|
||||
============================================ */
|
||||
.activity-timeline-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 14px;
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Strength variants */
|
||||
.activity-timeline-bar[data-strength="1"] { height: 5px; }
|
||||
.activity-timeline-bar[data-strength="2"] { height: 9px; }
|
||||
.activity-timeline-bar[data-strength="3"] { height: 13px; }
|
||||
.activity-timeline-bar[data-strength="4"] { height: 17px; }
|
||||
.activity-timeline-bar[data-strength="5"] { height: 21px; }
|
||||
|
||||
/* Status colors */
|
||||
.activity-timeline-bar[data-status="new"],
|
||||
.activity-timeline-bar[data-status="repeated"] {
|
||||
background: var(--timeline-status-new);
|
||||
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.activity-timeline-bar[data-status="baseline"] {
|
||||
background: var(--timeline-status-baseline);
|
||||
}
|
||||
|
||||
.activity-timeline-bar[data-status="burst"] {
|
||||
background: var(--timeline-status-burst);
|
||||
box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.activity-timeline-bar[data-status="flagged"] {
|
||||
background: var(--timeline-status-flagged);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||
animation: timeline-flagged-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes timeline-flagged-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.activity-timeline-lane:hover .activity-timeline-bar {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPANDED VIEW (tick marks)
|
||||
============================================ */
|
||||
.activity-timeline-ticks {
|
||||
display: none;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-lane.expanded .activity-timeline-ticks {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.activity-timeline-tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-tick[data-strength="1"] { height: 4px; }
|
||||
.activity-timeline-tick[data-strength="2"] { height: 8px; }
|
||||
.activity-timeline-tick[data-strength="3"] { height: 12px; }
|
||||
.activity-timeline-tick[data-strength="4"] { height: 16px; }
|
||||
.activity-timeline-tick[data-strength="5"] { height: 20px; }
|
||||
|
||||
/* ============================================
|
||||
STATS COLUMN
|
||||
============================================ */
|
||||
.activity-timeline-stats {
|
||||
width: 45px;
|
||||
min-width: 45px;
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
border-left: 1px solid var(--timeline-border);
|
||||
}
|
||||
|
||||
.activity-timeline-stat-count {
|
||||
color: var(--timeline-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-timeline-stat-label {
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ANNOTATIONS
|
||||
============================================ */
|
||||
.activity-timeline-annotations {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-timeline-annotation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-secondary);
|
||||
background: var(--timeline-bg-secondary);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-annotation-icon {
|
||||
font-size: 10px;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="new"] {
|
||||
border-left: 2px solid var(--timeline-status-new);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="burst"] {
|
||||
border-left: 2px solid var(--timeline-status-burst);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="pattern"] {
|
||||
border-left: 2px solid var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="flagged"] {
|
||||
border-left: 2px solid var(--timeline-status-flagged);
|
||||
color: var(--timeline-status-flagged);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="gone"] {
|
||||
border-left: 2px solid var(--timeline-status-gone);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOOLTIP
|
||||
============================================ */
|
||||
.activity-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--timeline-bg-elevated);
|
||||
border: 1px solid var(--timeline-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-primary);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 240px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.activity-timeline-tooltip-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--timeline-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.activity-timeline-tooltip-row span:last-child {
|
||||
color: var(--timeline-text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LEGEND
|
||||
============================================ */
|
||||
.activity-timeline-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-legend-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.activity-timeline-legend-dot.new { background: var(--timeline-status-new); }
|
||||
.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); }
|
||||
.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); }
|
||||
.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); }
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.activity-timeline-empty {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
color: var(--timeline-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.activity-timeline-empty-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* More indicator */
|
||||
.activity-timeline-more {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUAL MODE: COMPACT
|
||||
============================================ */
|
||||
.activity-timeline--compact .activity-timeline-lanes {
|
||||
max-height: 140px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-lane {
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-label {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-id {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-name {
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-secondary);
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-track {
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-bar {
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; }
|
||||
|
||||
.activity-timeline--compact .activity-timeline-stats {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-stat-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-legend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-axis {
|
||||
padding-left: 110px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUAL MODE: SUMMARY
|
||||
============================================ */
|
||||
.activity-timeline--summary .activity-timeline-lanes {
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-lane {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-label {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-id,
|
||||
.activity-timeline--summary .activity-timeline-name {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-status {
|
||||
width: 3px;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-track {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-bar {
|
||||
height: 8px !important;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-ticks {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-annotations {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-legend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-axis {
|
||||
padding-left: 90px;
|
||||
padding-right: 10px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BACKWARD COMPATIBILITY NOTE
|
||||
The old signal-timeline.css is still loaded
|
||||
for existing TSCM code that uses those classes.
|
||||
New code should use activity-timeline classes.
|
||||
============================================ */
|
||||
@@ -0,0 +1,879 @@
|
||||
/**
|
||||
* Device Cards Component CSS
|
||||
* Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS VARIABLES
|
||||
============================================ */
|
||||
:root {
|
||||
/* Protocol colors */
|
||||
--proto-ble: #3b82f6;
|
||||
--proto-ble-bg: rgba(59, 130, 246, 0.15);
|
||||
--proto-classic: #8b5cf6;
|
||||
--proto-classic-bg: rgba(139, 92, 246, 0.15);
|
||||
|
||||
/* Range band colors */
|
||||
--range-very-close: #ef4444;
|
||||
--range-close: #f97316;
|
||||
--range-nearby: #eab308;
|
||||
--range-far: #6b7280;
|
||||
--range-unknown: #374151;
|
||||
|
||||
/* Heuristic badge colors */
|
||||
--heuristic-new: #3b82f6;
|
||||
--heuristic-persistent: #22c55e;
|
||||
--heuristic-beacon: #f59e0b;
|
||||
--heuristic-strong: #ef4444;
|
||||
--heuristic-random: #6b7280;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DEVICE CARD BASE
|
||||
============================================ */
|
||||
.device-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.device-card:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DEVICE IDENTITY
|
||||
============================================ */
|
||||
.device-identity {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.device-address .address-value {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.device-address .address-type {
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROTOCOL BADGES
|
||||
============================================ */
|
||||
.signal-proto-badge.device-protocol {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEURISTIC BADGES
|
||||
============================================ */
|
||||
.device-heuristic-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||
color: var(--badge-color);
|
||||
border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.new {
|
||||
--badge-color: var(--heuristic-new);
|
||||
animation: heuristicPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.device-heuristic-badge.persistent {
|
||||
--badge-color: var(--heuristic-persistent);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.beacon_like {
|
||||
--badge-color: var(--heuristic-beacon);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.strong_stable {
|
||||
--badge-color: var(--heuristic-strong);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.random_address {
|
||||
--badge-color: var(--heuristic-random);
|
||||
}
|
||||
|
||||
@keyframes heuristicPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL ROW & RSSI DISPLAY
|
||||
============================================ */
|
||||
.device-signal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rssi-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rssi-current {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RSSI SPARKLINE
|
||||
============================================ */
|
||||
.rssi-sparkline,
|
||||
.rssi-sparkline-svg {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rssi-sparkline-empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rssi-sparkline-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rssi-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rssi-current-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sparkline-dot {
|
||||
animation: sparklinePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sparklinePulse {
|
||||
0%, 100% { r: 2; opacity: 1; }
|
||||
50% { r: 3; opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RANGE BAND INDICATOR
|
||||
============================================ */
|
||||
.device-range-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: color-mix(in srgb, var(--range-color) 15%, transparent);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--range-color);
|
||||
}
|
||||
|
||||
.device-range-band .range-label {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--range-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.device-range-band .range-estimate {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.device-range-band .range-confidence {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
padding: 1px 4px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MANUFACTURER INFO
|
||||
============================================ */
|
||||
.device-manufacturer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.device-manufacturer .mfr-icon {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-manufacturer .mfr-name {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
META ROW
|
||||
============================================ */
|
||||
.device-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.device-seen-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.device-seen-count .seen-icon {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-timestamp {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SERVICE UUIDS
|
||||
============================================ */
|
||||
.device-uuids {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-uuid {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary, #888);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEURISTICS DETAIL VIEW
|
||||
============================================ */
|
||||
.device-heuristics-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.heuristic-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heuristic-item.active {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.heuristic-item .heuristic-name {
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.heuristic-item .heuristic-status {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heuristic-item.active .heuristic-status {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.heuristic-item:not(.active) .heuristic-status {
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MESSAGE CARDS
|
||||
============================================ */
|
||||
.message-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--message-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
animation: messageSlideIn 0.25s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message-card.message-card-hiding {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.message-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--message-color);
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.message-card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--message-color);
|
||||
}
|
||||
|
||||
.message-card-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-card-icon svg.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message-card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-card-title {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.message-card-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-card-details {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.message-card-dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim, #666);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.message-card-dismiss:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.message-card-dismiss svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.message-action-btn {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.message-action-btn:hover {
|
||||
background: var(--bg-tertiary, #252525);
|
||||
border-color: var(--border-light, #444);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.message-action-btn.primary {
|
||||
background: color-mix(in srgb, var(--message-color) 20%, transparent);
|
||||
border-color: color-mix(in srgb, var(--message-color) 40%, transparent);
|
||||
color: var(--message-color);
|
||||
}
|
||||
|
||||
.message-action-btn.primary:hover {
|
||||
background: color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DEVICE FILTER BAR
|
||||
============================================ */
|
||||
.device-filter-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.device-filter-bar .signal-filter-btn .filter-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
.device-signal-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rssi-display {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-range-band {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-heuristics-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.message-card-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-card-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BLUETOOTH DEVICE LIST CONTAINER
|
||||
============================================ */
|
||||
#btDeviceListContent {
|
||||
display: block !important;
|
||||
padding: 10px !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Pure inline-styled cards - ensure no interference */
|
||||
#btDeviceListContent > div[data-bt-device-id] {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Legacy card support */
|
||||
#btDeviceListContent .device-card,
|
||||
#btDeviceListContent .signal-card {
|
||||
margin: 0 0 10px 0;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Ensure card body is visible */
|
||||
.device-card .signal-card-body,
|
||||
.signal-card .signal-card-body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 8px !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-identity,
|
||||
.signal-card .device-identity {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-signal-row,
|
||||
.signal-card .device-signal-row {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-meta-row,
|
||||
.signal-card .device-meta-row {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ENHANCED MODAL STYLES
|
||||
============================================ */
|
||||
.signal-details-modal-header .modal-header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.signal-details-modal-subtitle {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-details-modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.signal-details-copy-addr-btn {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.signal-details-copy-addr-btn:hover {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Modal Header Section */
|
||||
.modal-device-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.modal-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Modal Sections */
|
||||
.modal-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-section-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim, #666);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Signal Display */
|
||||
.modal-signal-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-rssi-large {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-rssi-large .rssi-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim, #666);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.modal-sparkline {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Signal Stats Grid */
|
||||
.modal-signal-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim, #666);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.modal-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-info-grid .info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.modal-info-grid .info-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.modal-info-grid .info-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.modal-info-grid .info-value.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* UUID List */
|
||||
.modal-uuid-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal-uuid {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
/* Heuristics Grid */
|
||||
.modal-heuristics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heuristic-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heuristic-check.active {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.heuristic-indicator {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.heuristic-check.active .heuristic-indicator {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.heuristic-label {
|
||||
font-size: 11px;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE MODAL
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
.modal-signal-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.modal-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-signal-display {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-sparkline {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-device-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DARK MODE OVERRIDES (if needed)
|
||||
============================================ */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.device-card {
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #141414;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Proximity Visualization Components
|
||||
* Styles for radar and timeline heatmap
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY RADAR
|
||||
============================================ */
|
||||
|
||||
.proximity-radar-svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.radar-device:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
animation: radar-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover {
|
||||
background: var(--bg-hover, #333) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active {
|
||||
background: #00d4ff !important;
|
||||
color: #000 !important;
|
||||
border-color: #00d4ff !important;
|
||||
}
|
||||
|
||||
#btRadarPauseBtn.active {
|
||||
background: #f97316 !important;
|
||||
color: #000 !important;
|
||||
border-color: #f97316 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE HEATMAP
|
||||
============================================ */
|
||||
|
||||
.timeline-heatmap-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
border-color: var(--accent-color, #00d4ff);
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 10px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-btn:hover {
|
||||
background: var(--bg-hover, #252525);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.heatmap-btn.active {
|
||||
background: #f97316;
|
||||
color: #000;
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.timeline-heatmap-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-loading,
|
||||
.heatmap-empty,
|
||||
.heatmap-error {
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heatmap-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-row:hover:not(.heatmap-header) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.heatmap-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-label .device-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.heatmap-label .device-rssi {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 2px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZONE SUMMARY
|
||||
============================================ */
|
||||
|
||||
#btZoneSummary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#btZoneSummary > div {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-heatmap-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proximity-radar-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btZoneSummary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Signal Activity Timeline Component
|
||||
* Lightweight visualization for RF signal presence over time
|
||||
* Used for TSCM sweeps and investigative analysis
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
TIMELINE CONTAINER
|
||||
============================================ */
|
||||
.signal-timeline {
|
||||
background: var(--bg-card, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.signal-timeline.collapsed .signal-timeline-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signal-timeline.collapsed .signal-timeline-header {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.signal-timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.signal-timeline-header:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.signal-timeline-body {
|
||||
padding: 0 12px 12px 12px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.signal-timeline-collapse-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.signal-timeline.collapsed .signal-timeline-collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.signal-timeline-header-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-header-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-header-stat .stat-value {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-timeline-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.signal-timeline-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signal-timeline-btn {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 9px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.signal-timeline-btn:hover {
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.signal-timeline-btn.active {
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
color: #000;
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
/* Time window selector */
|
||||
.signal-timeline-window {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-window select {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 9px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIME AXIS
|
||||
============================================ */
|
||||
.signal-timeline-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 80px 0 100px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-axis-label {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.signal-timeline-axis-label::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--border-color, #333);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SWIMLANES
|
||||
============================================ */
|
||||
.signal-timeline-lanes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #444);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-lane {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 36px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.signal-timeline-lane:hover {
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
}
|
||||
|
||||
.signal-timeline-lane.expanded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.baseline {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.baseline:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Signal label */
|
||||
.signal-timeline-label {
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
border-right: 1px solid var(--border-color, #333);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.signal-timeline-freq {
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.signal-timeline-name {
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.signal-timeline-status {
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="new"] {
|
||||
background: var(--signal-new, #3b82f6);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="baseline"] {
|
||||
background: var(--signal-baseline, #6b7280);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="burst"] {
|
||||
background: var(--signal-burst, #f59e0b);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="flagged"] {
|
||||
background: var(--signal-emergency, #ef4444);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="gone"] {
|
||||
background: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TRACK (where bars are drawn)
|
||||
============================================ */
|
||||
.signal-timeline-track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signal-timeline-track-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Grid lines */
|
||||
.signal-timeline-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-color, #333);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL BARS
|
||||
============================================ */
|
||||
.signal-timeline-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 16px;
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Strength variants (height) */
|
||||
.signal-timeline-bar[data-strength="1"] { height: 6px; }
|
||||
.signal-timeline-bar[data-strength="2"] { height: 10px; }
|
||||
.signal-timeline-bar[data-strength="3"] { height: 14px; }
|
||||
.signal-timeline-bar[data-strength="4"] { height: 18px; }
|
||||
.signal-timeline-bar[data-strength="5"] { height: 22px; }
|
||||
|
||||
/* Status colors */
|
||||
.signal-timeline-bar[data-status="new"] {
|
||||
background: var(--signal-new, #3b82f6);
|
||||
box-shadow: 0 0 6px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="baseline"] {
|
||||
background: var(--signal-baseline, #6b7280);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="burst"] {
|
||||
background: var(--signal-burst, #f59e0b);
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="flagged"] {
|
||||
background: var(--signal-emergency, #ef4444);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
animation: flaggedPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes flaggedPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.signal-timeline-lane:hover .signal-timeline-bar {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPANDED VIEW (tick marks)
|
||||
============================================ */
|
||||
.signal-timeline-ticks {
|
||||
display: none;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.expanded .signal-timeline-ticks {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.signal-timeline-tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-tick[data-strength="1"] { height: 4px; }
|
||||
.signal-timeline-tick[data-strength="2"] { height: 8px; }
|
||||
.signal-timeline-tick[data-strength="3"] { height: 12px; }
|
||||
.signal-timeline-tick[data-strength="4"] { height: 16px; }
|
||||
.signal-timeline-tick[data-strength="5"] { height: 20px; }
|
||||
|
||||
/* ============================================
|
||||
ANNOTATIONS
|
||||
============================================ */
|
||||
.signal-timeline-annotations {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation {
|
||||
padding: 3px 6px;
|
||||
font-size: 9px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="new"] {
|
||||
border-left: 2px solid var(--signal-new, #3b82f6);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="burst"] {
|
||||
border-left: 2px solid var(--signal-burst, #f59e0b);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="pattern"] {
|
||||
border-left: 2px solid var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="flagged"] {
|
||||
border-left: 2px solid var(--signal-emergency, #ef4444);
|
||||
color: var(--signal-emergency, #ef4444);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOOLTIP
|
||||
============================================ */
|
||||
.signal-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #fff);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-row span:last-child {
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS ROW
|
||||
============================================ */
|
||||
.signal-timeline-stats {
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
border-left: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.signal-timeline-stat-count {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-timeline-stat-label {
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.signal-timeline-empty {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.signal-timeline-empty-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LEGEND - compact inline version
|
||||
============================================ */
|
||||
.signal-timeline-legend {
|
||||
display: none; /* Hide by default - status colors are self-explanatory */
|
||||
}
|
||||
|
||||
.signal-timeline-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.signal-timeline-legend-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); }
|
||||
.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); }
|
||||
.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); }
|
||||
.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); }
|
||||
|
||||
/* ============================================
|
||||
NOW MARKER
|
||||
============================================ */
|
||||
.signal-timeline-now {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent-green, #22c55e);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.signal-timeline-now::after {
|
||||
content: 'NOW';
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--accent-green, #22c55e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MARKER (first seen indicator)
|
||||
============================================ */
|
||||
.signal-timeline-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-bottom: 8px solid var(--signal-new, #3b82f6);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.signal-timeline-marker::after {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--signal-new, #3b82f6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* 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');
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Container Layout */
|
||||
.landing-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column; /* Stack logo, title, box vertically */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Background Effects */
|
||||
.landing-scanline {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
animation: scanlineMove 5s linear infinite;
|
||||
opacity: 0.4;
|
||||
z-index: 1; /* Behind content */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes scanlineMove {
|
||||
0% { top: 0; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.landing-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4em;
|
||||
color: var(--text-primary);
|
||||
margin: 20px 0 5px 0;
|
||||
text-indent: 0.4em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* The Login Box */
|
||||
.login-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 30px;
|
||||
border-radius: 4px;
|
||||
width: 380px;
|
||||
z-index: 20;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 20px var(--accent-cyan-dim);
|
||||
box-sizing: border-box; /* Ensures padding doesn't hide inputs */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hacker Style Error */
|
||||
.flash-error {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--accent-red);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-left: 3px solid var(--accent-red);
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
text-transform: uppercase;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.error-prefix { font-weight: 700; opacity: 0.7; }
|
||||
|
||||
/* Inputs */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
box-sizing: border-box; /* Crucial for visibility */
|
||||
}
|
||||
|
||||
.landing-enter-btn {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 2px solid var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
padding: 15px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
letter-spacing: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.landing-version {
|
||||
margin-top: 25px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* ACARS Sidebar Styles */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Main ACARS Sidebar (Collapsible) */
|
||||
.main-acars-sidebar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: var(--bg-panel);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
.main-acars-collapse-btn {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 6px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.main-acars-collapse-btn:hover {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
.main-acars-collapse-label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; }
|
||||
.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; }
|
||||
#mainAcarsCollapseIcon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.main-acars-sidebar.collapsed #mainAcarsCollapseIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.main-acars-content {
|
||||
width: 196px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
.main-acars-sidebar.collapsed .main-acars-content {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.main-acars-messages {
|
||||
max-height: 350px;
|
||||
}
|
||||
.main-acars-msg {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
animation: fadeInMsg 0.3s ease;
|
||||
}
|
||||
.main-acars-msg:hover {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
@keyframes fadeInMsg {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ACARS Status Indicator */
|
||||
.acars-status-dot.listening {
|
||||
background: var(--accent-cyan) !important;
|
||||
animation: acars-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.acars-status-dot.receiving {
|
||||
background: var(--accent-green) !important;
|
||||
}
|
||||
.acars-status-dot.error {
|
||||
background: var(--accent-red) !important;
|
||||
}
|
||||
@keyframes acars-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 6px 3px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Spy Stations Mode Styles
|
||||
* Number stations and diplomatic HF networks
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
MAIN LAYOUT
|
||||
============================================ */
|
||||
.spy-stations-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spy-stations-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.spy-stations-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spy-stations-title svg {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.spy-stations-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATION GRID
|
||||
============================================ */
|
||||
.spy-stations-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 4px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATION CARD
|
||||
============================================ */
|
||||
.spy-station-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spy-station-card:hover {
|
||||
border-color: var(--border-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.spy-station-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.spy-station-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spy-station-flag {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spy-station-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.spy-station-nickname {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Type Badge */
|
||||
.spy-station-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spy-badge-number {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.spy-badge-diplomatic {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
/* Card Body */
|
||||
.spy-station-body {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spy-station-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spy-station-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.spy-meta-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.spy-meta-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.spy-meta-mode {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
/* Frequencies */
|
||||
.spy-station-freqs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.spy-freq-list {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.spy-freq-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.spy-freq-item {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.spy-station-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Card Footer */
|
||||
.spy-station-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Frequency Selector Group */
|
||||
.spy-tune-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spy-freq-select {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spy-freq-select:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.spy-freq-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Clickable frequency items in details modal */
|
||||
.spy-freq-clickable {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.spy-freq-clickable:hover {
|
||||
background: var(--accent-cyan);
|
||||
color: #000;
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Tune Button */
|
||||
.spy-tune-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #000;
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.spy-tune-btn:hover {
|
||||
background: var(--accent-cyan);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.spy-tune-btn svg {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* Details Button */
|
||||
.spy-details-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.spy-details-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.spy-station-empty {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.spy-station-empty p {
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MODE VISIBILITY - Ensure sidebar shows when active
|
||||
============================================ */
|
||||
#spystationsMode.active {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FILTER CHECKBOX STYLING
|
||||
============================================ */
|
||||
#spystationsMode .inline-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
#spystationsMode .inline-checkbox input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
#spystationsMode .inline-checkbox:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
|
||||
/* Large desktop (1200px+) */
|
||||
@media (min-width: 1200px) {
|
||||
.spy-stations-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop/Tablet landscape (1024px) */
|
||||
@media (max-width: 1024px) {
|
||||
.spy-stations-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet portrait (768px) */
|
||||
@media (max-width: 768px) {
|
||||
.spy-stations-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.spy-station-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spy-station-badge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.spy-station-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small tablet / large phone (640px) */
|
||||
@media (max-width: 640px) {
|
||||
.spy-station-footer {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spy-tune-btn,
|
||||
.spy-details-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.spy-tune-group {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spy-freq-select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile (480px) */
|
||||
@media (max-width: 480px) {
|
||||
.spy-stations-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.spy-station-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.spy-stations-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.spy-station-desc {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device compliance */
|
||||
@media (pointer: coarse) {
|
||||
.spy-tune-btn,
|
||||
.spy-details-btn,
|
||||
.spy-freq-select {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.spy-freq-clickable {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
/* ============================================
|
||||
RESPONSIVE UTILITIES - iNTERCEPT
|
||||
Shared responsive foundation for all pages
|
||||
============================================ */
|
||||
|
||||
/* ============== CSS VARIABLES ============== */
|
||||
:root {
|
||||
/* Touch targets */
|
||||
--touch-min: 44px;
|
||||
--touch-comfortable: 48px;
|
||||
|
||||
/* Responsive spacing */
|
||||
--spacing-xs: clamp(4px, 1vw, 8px);
|
||||
--spacing-sm: clamp(8px, 2vw, 12px);
|
||||
--spacing-md: clamp(12px, 3vw, 20px);
|
||||
--spacing-lg: clamp(16px, 4vw, 32px);
|
||||
|
||||
/* Responsive typography */
|
||||
--font-xs: clamp(10px, 2.5vw, 11px);
|
||||
--font-sm: clamp(11px, 2.8vw, 12px);
|
||||
--font-base: clamp(13px, 3vw, 14px);
|
||||
--font-md: clamp(14px, 3.5vw, 16px);
|
||||
--font-lg: clamp(16px, 4vw, 20px);
|
||||
--font-xl: clamp(20px, 5vw, 28px);
|
||||
--font-2xl: clamp(24px, 6vw, 40px);
|
||||
|
||||
/* Header height for calculations */
|
||||
--header-height: 52px;
|
||||
--nav-height: 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--header-height: 60px;
|
||||
--nav-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--header-height: 96px;
|
||||
--nav-height: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== VIEWPORT HEIGHT FIX ============== */
|
||||
/* Handles iOS Safari address bar and dynamic viewport */
|
||||
.full-height {
|
||||
height: 100dvh;
|
||||
height: 100vh; /* Fallback */
|
||||
}
|
||||
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.full-height {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== HAMBURGER BUTTON ============== */
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: var(--touch-min);
|
||||
height: var(--touch-min);
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hamburger-btn:hover {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.hamburger-btn span {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
margin: 2px 0;
|
||||
border-radius: 1px;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger-btn.active span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(4px, 4px);
|
||||
}
|
||||
|
||||
.hamburger-btn.active span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hamburger-btn.active span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(4px, -4px);
|
||||
}
|
||||
|
||||
/* Hide hamburger on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.hamburger-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE DRAWER ============== */
|
||||
.mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(320px, 85vw);
|
||||
height: 100dvh;
|
||||
height: 100vh; /* Fallback */
|
||||
background: var(--bg-secondary, #0f1218);
|
||||
border-right: 1px solid var(--border-color, #1f2937);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.mobile-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Show sidebar normally on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.mobile-drawer {
|
||||
position: static;
|
||||
transform: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding-top: 0;
|
||||
z-index: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== DRAWER OVERLAY ============== */
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.drawer-overlay.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Hide overlay on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.drawer-overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TOUCH TARGETS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Ensure minimum touch target size for interactive elements */
|
||||
button,
|
||||
.btn,
|
||||
.preset-btn,
|
||||
.mode-nav-btn,
|
||||
.control-btn,
|
||||
.nav-action-btn,
|
||||
.icon-btn {
|
||||
min-height: var(--touch-min);
|
||||
min-width: var(--touch-min);
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="search"] {
|
||||
min-height: var(--touch-min);
|
||||
padding: 10px 12px;
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
}
|
||||
|
||||
.checkbox-group label,
|
||||
.radio-group label {
|
||||
min-height: var(--touch-min);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== RESPONSIVE UTILITIES ============== */
|
||||
/* Hide on mobile */
|
||||
.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hide-mobile {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on tablet and up */
|
||||
.show-mobile-only {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.show-mobile-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on desktop */
|
||||
.hide-desktop {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hide-desktop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Show only on desktop */
|
||||
.show-desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.show-desktop-only {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== SCROLLABLE AREAS ============== */
|
||||
.scroll-x {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.scroll-x::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.scroll-x::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #1f2937);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar on mobile for cleaner look */
|
||||
@media (max-width: 767px) {
|
||||
.scroll-x-mobile-hidden {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scroll-x-mobile-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE NAVIGATION BAR ============== */
|
||||
.mobile-nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mobile-nav-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card, #121620);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: var(--font-xs);
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.mobile-nav-btn:hover,
|
||||
.mobile-nav-btn.active {
|
||||
background: var(--bg-elevated, #1a202c);
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
color: var(--text-primary, #e8eaed);
|
||||
}
|
||||
|
||||
.mobile-nav-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide mobile nav bar on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.mobile-nav-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== RESPONSIVE GRID UTILITIES ============== */
|
||||
.grid-responsive {
|
||||
display: grid;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* 1 column base */
|
||||
.grid-1-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.grid-1-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-2-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid-2-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TYPOGRAPHY RESPONSIVE ============== */
|
||||
.text-responsive-xs { font-size: var(--font-xs); }
|
||||
.text-responsive-sm { font-size: var(--font-sm); }
|
||||
.text-responsive-base { font-size: var(--font-base); }
|
||||
.text-responsive-md { font-size: var(--font-md); }
|
||||
.text-responsive-lg { font-size: var(--font-lg); }
|
||||
.text-responsive-xl { font-size: var(--font-xl); }
|
||||
.text-responsive-2xl { font-size: var(--font-2xl); }
|
||||
|
||||
/* Ensure minimum readable sizes for tiny text */
|
||||
.text-min-readable {
|
||||
font-size: max(10px, var(--font-xs));
|
||||
}
|
||||
|
||||
/* ============== MOBILE LAYOUT FIXES ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Fix main content to allow scrolling on mobile */
|
||||
.main-content {
|
||||
height: auto !important;
|
||||
min-height: calc(100dvh - var(--header-height) - var(--nav-height));
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Container should not clip content */
|
||||
.container {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Layout containers need to stack vertically on mobile */
|
||||
.wifi-layout-container,
|
||||
.bt-layout-container {
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/* Visual panels should be scrollable, not clipped */
|
||||
.wifi-visuals,
|
||||
.bt-visuals {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Device lists should have reasonable height on mobile */
|
||||
.wifi-device-list,
|
||||
.bt-device-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Visual panels should stack in single column on mobile when visible */
|
||||
.wifi-visuals,
|
||||
.bt-visuals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
|
||||
#aircraftVisuals[style*="grid"] {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* APRS visuals - only when visible */
|
||||
#aprsVisuals[style*="flex"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.wifi-visual-panel {
|
||||
grid-column: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE MAP FIXES ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Aircraft map container needs explicit height on mobile */
|
||||
.aircraft-map-container {
|
||||
height: 300px !important;
|
||||
min-height: 300px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
#aircraftMap {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
/* APRS map container */
|
||||
#aprsMap {
|
||||
min-height: 300px !important;
|
||||
height: 300px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Satellite embed */
|
||||
.satellite-dashboard-embed {
|
||||
height: 400px !important;
|
||||
min-height: 400px !important;
|
||||
}
|
||||
|
||||
/* Map panels should be full width */
|
||||
.wifi-visual-panel[style*="grid-column: span 2"] {
|
||||
grid-column: auto !important;
|
||||
}
|
||||
|
||||
/* Make map container full width when it has ACARS sidebar */
|
||||
.wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* ACARS sidebar should be below map on mobile */
|
||||
.main-acars-sidebar {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
border-left: none !important;
|
||||
border-top: 1px solid var(--border-color, #1f2937) !important;
|
||||
}
|
||||
|
||||
.main-acars-sidebar.collapsed {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.main-acars-content {
|
||||
max-height: 200px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== LEAFLET MOBILE TOUCH FIXES ============== */
|
||||
.leaflet-container {
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
min-width: var(--touch-min, 44px) !important;
|
||||
min-height: var(--touch-min, 44px) !important;
|
||||
line-height: var(--touch-min, 44px) !important;
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
/* ============== MOBILE HEADER STATS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.header-stats {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Simplify header on mobile */
|
||||
header h1 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
header h1 .tagline,
|
||||
header h1 .version-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
font-size: 10px !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
header .logo svg {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE MODE PANELS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Mode panel grids should be single column */
|
||||
.data-grid,
|
||||
.stats-grid,
|
||||
.sensor-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
/* Section headers should be easier to tap */
|
||||
.section h3 {
|
||||
min-height: var(--touch-min);
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
/* Tables need horizontal scroll */
|
||||
.message-table,
|
||||
.sensor-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Ensure messages list is scrollable */
|
||||
#messageList,
|
||||
#sensorGrid,
|
||||
.aprs-list {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== WELCOME PAGE MOBILE ============== */
|
||||
@media (max-width: 767px) {
|
||||
.welcome-container {
|
||||
padding: 15px !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.welcome-logo svg {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
padding: 12px 8px !important;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
padding: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TSCM MODE MOBILE ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.tscm-layout {
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.tscm-spectrum-panel,
|
||||
.tscm-detection-panel {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== LISTENING POST MOBILE ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.radio-controls-section {
|
||||
flex-direction: column !important;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.knobs-row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radio-module-box {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,28 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Mobile header adjustments */
|
||||
@media (max-width: 800px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 14px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stats-badges {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
@@ -589,13 +611,14 @@ body {
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
box-shadow: 0 0 25px rgba(0, 212, 255, 0.5);
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 25px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
/* Leaflet dark theme overrides */
|
||||
@@ -673,24 +696,28 @@ body {
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.polar-container,
|
||||
.map-container {
|
||||
grid-column: 1;
|
||||
min-height: 300px;
|
||||
min-height: 250px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
flex-direction: column;
|
||||
max-height: none;
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
grid-row: 4;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/* 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%;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 585 KiB |
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* WiFi Channel Utilization Chart Component
|
||||
*
|
||||
* Displays channel utilization as a bar chart with recommendations.
|
||||
* Shows AP count, client count, and utilization score per channel.
|
||||
*/
|
||||
|
||||
const ChannelChart = (function() {
|
||||
'use strict';
|
||||
|
||||
// ==========================================================================
|
||||
// Configuration
|
||||
// ==========================================================================
|
||||
|
||||
const CONFIG = {
|
||||
height: 120,
|
||||
barWidth: 14,
|
||||
barSpacing: 2,
|
||||
padding: { top: 15, right: 10, bottom: 25, left: 30 },
|
||||
colors: {
|
||||
low: '#22c55e', // Green - low utilization
|
||||
medium: '#eab308', // Yellow - medium
|
||||
high: '#ef4444', // Red - high
|
||||
recommended: '#3b82f6', // Blue - recommended
|
||||
},
|
||||
thresholds: {
|
||||
low: 0.3,
|
||||
medium: 0.6,
|
||||
},
|
||||
};
|
||||
|
||||
// 2.4 GHz non-overlapping channels
|
||||
const CHANNELS_2_4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const NON_OVERLAPPING_2_4 = [1, 6, 11];
|
||||
|
||||
// 5 GHz channels (non-DFS)
|
||||
const CHANNELS_5 = [36, 40, 44, 48, 149, 153, 157, 161, 165];
|
||||
|
||||
// ==========================================================================
|
||||
// State
|
||||
// ==========================================================================
|
||||
|
||||
let container = null;
|
||||
let currentBand = '2.4';
|
||||
let channelStats = [];
|
||||
let recommendations = [];
|
||||
|
||||
// ==========================================================================
|
||||
// Initialization
|
||||
// ==========================================================================
|
||||
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.warn('[ChannelChart] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(CONFIG, options);
|
||||
render();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Update
|
||||
// ==========================================================================
|
||||
|
||||
function update(stats, recs) {
|
||||
channelStats = stats || [];
|
||||
recommendations = recs || [];
|
||||
render();
|
||||
}
|
||||
|
||||
function setBand(band) {
|
||||
currentBand = band;
|
||||
render();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Rendering
|
||||
// ==========================================================================
|
||||
|
||||
function render() {
|
||||
if (!container) return;
|
||||
|
||||
const channels = currentBand === '2.4' ? CHANNELS_2_4 : CHANNELS_5;
|
||||
const nonOverlapping = currentBand === '2.4' ? NON_OVERLAPPING_2_4 : CHANNELS_5;
|
||||
|
||||
// Build stats map
|
||||
const statsMap = {};
|
||||
channelStats.forEach(s => {
|
||||
statsMap[s.channel] = s;
|
||||
});
|
||||
|
||||
// Build recommendations map
|
||||
const recsMap = {};
|
||||
recommendations.forEach((r, i) => {
|
||||
recsMap[r.channel] = { rank: i + 1, ...r };
|
||||
});
|
||||
|
||||
// Calculate dimensions
|
||||
const width = channels.length * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.padding.left + CONFIG.padding.right;
|
||||
const height = CONFIG.height + CONFIG.padding.top + CONFIG.padding.bottom;
|
||||
const chartHeight = CONFIG.height;
|
||||
|
||||
// Find max values for scaling
|
||||
let maxApCount = 1;
|
||||
channelStats.forEach(s => {
|
||||
if (s.ap_count > maxApCount) maxApCount = s.ap_count;
|
||||
});
|
||||
|
||||
// Build SVG with viewBox for responsive scaling
|
||||
let svg = `
|
||||
<svg viewBox="0 0 ${width} ${height}" class="channel-chart-svg" style="width: 100%; height: auto; max-height: ${height}px;">
|
||||
<defs>
|
||||
<linearGradient id="utilGradientLow" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.9" />
|
||||
<stop offset="100%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="utilGradientMed" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.9" />
|
||||
<stop offset="100%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="utilGradientHigh" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.9" />
|
||||
<stop offset="100%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Y-axis label -->
|
||||
<text x="10" y="${height / 2}" fill="#666" font-size="10" transform="rotate(-90, 10, ${height / 2})" text-anchor="middle">APs</text>
|
||||
|
||||
<!-- Y-axis ticks -->
|
||||
${renderYAxis(chartHeight, maxApCount)}
|
||||
|
||||
<!-- Bars -->
|
||||
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top})">
|
||||
${channels.map((ch, i) => {
|
||||
const stats = statsMap[ch] || { ap_count: 0, utilization_score: 0 };
|
||||
const rec = recsMap[ch];
|
||||
const isNonOverlapping = nonOverlapping.includes(ch);
|
||||
return renderBar(i, ch, stats, rec, isNonOverlapping, chartHeight, maxApCount);
|
||||
}).join('')}
|
||||
</g>
|
||||
|
||||
<!-- X-axis labels -->
|
||||
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top + chartHeight + 5})">
|
||||
${channels.map((ch, i) => {
|
||||
const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2;
|
||||
const isNonOverlapping = nonOverlapping.includes(ch);
|
||||
return `<text x="${x}" y="12" fill="${isNonOverlapping ? '#fff' : '#666'}" font-size="9" text-anchor="middle">${ch}</text>`;
|
||||
}).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Add legend
|
||||
svg += renderLegend();
|
||||
|
||||
// Add recommendations
|
||||
if (recommendations.length > 0) {
|
||||
svg += renderRecommendations();
|
||||
}
|
||||
|
||||
container.innerHTML = svg;
|
||||
}
|
||||
|
||||
function renderYAxis(chartHeight, maxApCount) {
|
||||
const ticks = [];
|
||||
const tickCount = Math.min(5, maxApCount);
|
||||
const step = Math.ceil(maxApCount / tickCount);
|
||||
|
||||
for (let i = 0; i <= maxApCount; i += step) {
|
||||
const y = CONFIG.padding.top + chartHeight - (i / maxApCount * chartHeight);
|
||||
ticks.push(`
|
||||
<line x1="${CONFIG.padding.left - 5}" y1="${y}" x2="${CONFIG.padding.left}" y2="${y}" stroke="#444" />
|
||||
<text x="${CONFIG.padding.left - 8}" y="${y + 3}" fill="#666" font-size="9" text-anchor="end">${i}</text>
|
||||
`);
|
||||
}
|
||||
|
||||
return ticks.join('');
|
||||
}
|
||||
|
||||
function renderBar(index, channel, stats, rec, isNonOverlapping, chartHeight, maxApCount) {
|
||||
const x = index * (CONFIG.barWidth + CONFIG.barSpacing);
|
||||
const barHeight = (stats.ap_count / maxApCount) * chartHeight;
|
||||
const y = chartHeight - barHeight;
|
||||
|
||||
// Determine color based on utilization
|
||||
let gradient = 'utilGradientLow';
|
||||
if (stats.utilization_score >= CONFIG.thresholds.medium) {
|
||||
gradient = 'utilGradientHigh';
|
||||
} else if (stats.utilization_score >= CONFIG.thresholds.low) {
|
||||
gradient = 'utilGradientMed';
|
||||
}
|
||||
|
||||
// Recommended channel indicator
|
||||
const isRecommended = rec && rec.rank <= 3;
|
||||
const recIndicator = isRecommended ?
|
||||
`<circle cx="${x + CONFIG.barWidth / 2}" cy="${chartHeight + 20}" r="4" fill="${CONFIG.colors.recommended}" />
|
||||
<text x="${x + CONFIG.barWidth / 2}" y="${chartHeight + 23}" fill="#fff" font-size="7" text-anchor="middle">${rec.rank}</text>` : '';
|
||||
|
||||
// Non-overlapping channel marker
|
||||
const channelMarker = isNonOverlapping ?
|
||||
`<rect x="${x}" y="${chartHeight}" width="${CONFIG.barWidth}" height="2" fill="#3b82f6" />` : '';
|
||||
|
||||
return `
|
||||
<g class="channel-bar" data-channel="${channel}">
|
||||
<!-- Bar background -->
|
||||
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
||||
fill="#1a1a2e" rx="2" />
|
||||
|
||||
<!-- Utilization bar -->
|
||||
<rect x="${x}" y="${y}" width="${CONFIG.barWidth}" height="${barHeight}"
|
||||
fill="url(#${gradient})" rx="2" />
|
||||
|
||||
<!-- AP count label -->
|
||||
${stats.ap_count > 0 ? `
|
||||
<text x="${x + CONFIG.barWidth / 2}" y="${y - 4}" fill="#fff" font-size="9" text-anchor="middle">
|
||||
${stats.ap_count}
|
||||
</text>
|
||||
` : ''}
|
||||
|
||||
${channelMarker}
|
||||
${recIndicator}
|
||||
|
||||
<!-- Hover area -->
|
||||
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
||||
fill="transparent" class="channel-hover" />
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
return `
|
||||
<div class="channel-chart-legend" style="display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.low}; border-radius: 2px;"></span>
|
||||
<span style="color: #888;">Low</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.medium}; border-radius: 2px;"></span>
|
||||
<span style="color: #888;">Medium</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.high}; border-radius: 2px;"></span>
|
||||
<span style="color: #888;">High</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 3px; background: #3b82f6; border-radius: 1px;"></span>
|
||||
<span style="color: #888;">Non-overlapping</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecommendations() {
|
||||
const topRecs = recommendations.slice(0, 3);
|
||||
if (topRecs.length === 0) return '';
|
||||
|
||||
return `
|
||||
<div class="channel-chart-recommendations" style="margin-top: 12px; padding: 8px; background: #1a1a2e; border-radius: 4px;">
|
||||
<div style="font-size: 10px; color: #888; margin-bottom: 6px;">Recommended Channels:</div>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
${topRecs.map((rec, i) => `
|
||||
<div style="display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: ${i === 0 ? 'rgba(59, 130, 246, 0.2)' : '#0d0d1a'}; border-radius: 4px; border: 1px solid ${i === 0 ? '#3b82f6' : '#333'};">
|
||||
<span style="font-size: 11px; font-weight: bold; color: ${i === 0 ? '#3b82f6' : '#666'};">#${i + 1}</span>
|
||||
<span style="font-size: 12px; color: #fff;">Ch ${rec.channel}</span>
|
||||
<span style="font-size: 9px; color: #666;">(${rec.band})</span>
|
||||
${rec.is_dfs ? '<span style="font-size: 8px; color: #ff6b6b; margin-left: 4px;">DFS</span>' : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API
|
||||
// ==========================================================================
|
||||
|
||||
return {
|
||||
init,
|
||||
update,
|
||||
setBand,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* Device Card Component
|
||||
* Unified device display for Bluetooth and TSCM modes
|
||||
*/
|
||||
|
||||
const DeviceCard = (function() {
|
||||
'use strict';
|
||||
|
||||
// Range band configuration
|
||||
const RANGE_BANDS = {
|
||||
very_close: { label: 'Very Close', color: '#ef4444', description: '< 3m' },
|
||||
close: { label: 'Close', color: '#f97316', description: '3-10m' },
|
||||
nearby: { label: 'Nearby', color: '#eab308', description: '10-20m' },
|
||||
far: { label: 'Far', color: '#6b7280', description: '> 20m' },
|
||||
unknown: { label: 'Unknown', color: '#374151', description: 'N/A' }
|
||||
};
|
||||
|
||||
// Protocol badge colors
|
||||
const PROTOCOL_COLORS = {
|
||||
ble: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
|
||||
classic: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', border: 'rgba(139, 92, 246, 0.3)' }
|
||||
};
|
||||
|
||||
// Heuristic badge configuration
|
||||
const HEURISTIC_BADGES = {
|
||||
new: { label: 'New', color: '#3b82f6', description: 'Not in baseline' },
|
||||
persistent: { label: 'Persistent', color: '#22c55e', description: 'Continuously present' },
|
||||
beacon_like: { label: 'Beacon', color: '#f59e0b', description: 'Regular advertising' },
|
||||
strong_stable: { label: 'Strong', color: '#ef4444', description: 'Strong stable signal' },
|
||||
random_address: { label: 'Random', color: '#6b7280', description: 'Privacy address' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(isoString) {
|
||||
if (!isoString) return '';
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diff < 10) return 'Just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RSSI sparkline SVG
|
||||
*/
|
||||
function createSparkline(rssiHistory, options = {}) {
|
||||
if (!rssiHistory || rssiHistory.length < 2) {
|
||||
return '<span class="rssi-sparkline-empty">--</span>';
|
||||
}
|
||||
|
||||
const width = options.width || 60;
|
||||
const height = options.height || 20;
|
||||
const samples = rssiHistory.slice(-20); // Last 20 samples
|
||||
|
||||
// Normalize RSSI values (-100 to -30 range)
|
||||
const minRssi = -100;
|
||||
const maxRssi = -30;
|
||||
const normalizedValues = samples.map(s => {
|
||||
const rssi = s.rssi || s;
|
||||
const normalized = (rssi - minRssi) / (maxRssi - minRssi);
|
||||
return Math.max(0, Math.min(1, normalized));
|
||||
});
|
||||
|
||||
// Generate path
|
||||
const stepX = width / (normalizedValues.length - 1);
|
||||
let pathD = '';
|
||||
normalizedValues.forEach((val, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (val * height);
|
||||
pathD += i === 0 ? `M${x},${y}` : ` L${x},${y}`;
|
||||
});
|
||||
|
||||
// Determine color based on latest value
|
||||
const latestRssi = samples[samples.length - 1].rssi || samples[samples.length - 1];
|
||||
let strokeColor = '#6b7280';
|
||||
if (latestRssi > -50) strokeColor = '#22c55e';
|
||||
else if (latestRssi > -65) strokeColor = '#f59e0b';
|
||||
else if (latestRssi > -80) strokeColor = '#f97316';
|
||||
|
||||
return `
|
||||
<svg class="rssi-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create heuristic badges HTML
|
||||
*/
|
||||
function createHeuristicBadges(flags) {
|
||||
if (!flags || flags.length === 0) return '';
|
||||
|
||||
return flags.map(flag => {
|
||||
const config = HEURISTIC_BADGES[flag];
|
||||
if (!config) return '';
|
||||
return `
|
||||
<span class="device-heuristic-badge ${flag}"
|
||||
style="--badge-color: ${config.color}"
|
||||
title="${escapeHtml(config.description)}">
|
||||
${escapeHtml(config.label)}
|
||||
</span>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create range band indicator
|
||||
*/
|
||||
function createRangeBand(band, confidence) {
|
||||
const config = RANGE_BANDS[band] || RANGE_BANDS.unknown;
|
||||
const confidencePercent = Math.round((confidence || 0) * 100);
|
||||
|
||||
return `
|
||||
<div class="device-range-band" style="--range-color: ${config.color}">
|
||||
<span class="range-label">${escapeHtml(config.label)}</span>
|
||||
<span class="range-estimate">${escapeHtml(config.description)}</span>
|
||||
${confidence > 0 ? `<span class="range-confidence" title="Confidence">${confidencePercent}%</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create protocol badge
|
||||
*/
|
||||
function createProtocolBadge(protocol) {
|
||||
const config = PROTOCOL_COLORS[protocol] || PROTOCOL_COLORS.ble;
|
||||
const label = protocol === 'classic' ? 'Classic' : 'BLE';
|
||||
|
||||
return `
|
||||
<span class="signal-proto-badge device-protocol"
|
||||
style="background: ${config.bg}; color: ${config.color}; border-color: ${config.border}">
|
||||
${escapeHtml(label)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Bluetooth device card
|
||||
*/
|
||||
function createDeviceCard(device, options = {}) {
|
||||
// Debug: log received device data
|
||||
console.log('[DeviceCard] Creating card for:', device.address, device);
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = 'signal-card device-card';
|
||||
card.dataset.deviceId = device.device_id || '';
|
||||
card.dataset.protocol = device.protocol || 'ble';
|
||||
card.dataset.address = device.address || '';
|
||||
|
||||
// Add status classes
|
||||
if (device.heuristic_flags && device.heuristic_flags.includes('new')) {
|
||||
card.dataset.status = 'new';
|
||||
} else if (device.in_baseline) {
|
||||
card.dataset.status = 'baseline';
|
||||
}
|
||||
|
||||
// Store full device data for details modal
|
||||
try {
|
||||
card.dataset.deviceData = JSON.stringify(device);
|
||||
} catch (e) {
|
||||
card.dataset.deviceData = '{}';
|
||||
}
|
||||
|
||||
const relativeTime = formatRelativeTime(device.last_seen) || 'Unknown';
|
||||
const sparkline = createSparkline(device.rssi_history) || '';
|
||||
const heuristicBadges = createHeuristicBadges(device.heuristic_flags) || '';
|
||||
const rangeBand = createRangeBand(device.range_band, device.range_confidence) || '';
|
||||
const protocolBadge = createProtocolBadge(device.protocol) || '';
|
||||
|
||||
// Build card with explicit defaults for all values
|
||||
const deviceName = device.name || device.device_id || 'Unknown Device';
|
||||
const deviceAddress = device.address || 'Unknown';
|
||||
const addressType = device.address_type || 'unknown';
|
||||
const rssiDisplay = (device.rssi_current !== null && device.rssi_current !== undefined)
|
||||
? device.rssi_current + ' dBm' : '--';
|
||||
const seenCount = device.seen_count || 0;
|
||||
const inBaseline = device.in_baseline || false;
|
||||
const mfrName = device.manufacturer_name || '';
|
||||
|
||||
// Build the HTML parts separately to avoid template issues
|
||||
const headerHtml = '<div class="signal-card-header">' +
|
||||
'<div class="signal-card-badges">' + protocolBadge + heuristicBadges + '</div>' +
|
||||
'<span class="signal-status-pill" data-status="' + (inBaseline ? 'baseline' : 'new') + '">' +
|
||||
'<span class="status-dot"></span>' + (inBaseline ? 'Known' : 'New') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
const identityHtml = '<div class="device-identity">' +
|
||||
'<div class="device-name">' + escapeHtml(deviceName) + '</div>' +
|
||||
'<div class="device-address">' +
|
||||
'<span class="address-value">' + escapeHtml(deviceAddress) + '</span>' +
|
||||
'<span class="address-type">(' + escapeHtml(addressType) + ')</span>' +
|
||||
'</div></div>';
|
||||
|
||||
const signalHtml = '<div class="device-signal-row">' +
|
||||
'<div class="rssi-display">' +
|
||||
'<span class="rssi-current" title="Current RSSI">' + rssiDisplay + '</span>' +
|
||||
sparkline + '</div>' + rangeBand + '</div>';
|
||||
|
||||
const mfrHtml = mfrName ?
|
||||
'<div class="device-manufacturer">' +
|
||||
'<span class="mfr-icon">🏭</span>' +
|
||||
'<span class="mfr-name">' + escapeHtml(mfrName) + '</span></div>' : '';
|
||||
|
||||
const metaHtml = '<div class="device-meta-row">' +
|
||||
'<span class="device-seen-count" title="Observation count">' +
|
||||
'<span class="seen-icon">👁</span>' + seenCount + '×</span>' +
|
||||
'<span class="device-timestamp" data-timestamp="' + escapeHtml(device.last_seen || '') + '">' +
|
||||
escapeHtml(relativeTime) + '</span></div>';
|
||||
|
||||
const bodyHtml = '<div class="signal-card-body">' +
|
||||
identityHtml + signalHtml + mfrHtml + metaHtml + '</div>';
|
||||
|
||||
card.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
// Make card clickable - opens modal with full details
|
||||
card.addEventListener('click', () => {
|
||||
showDeviceDetails(device);
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create advanced panel content
|
||||
*/
|
||||
function createAdvancedPanel(device) {
|
||||
return `
|
||||
<div class="signal-advanced-content">
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Device Details</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Address</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(device.address)}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Address Type</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(device.address_type)}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Protocol</span>
|
||||
<span class="signal-advanced-value">${device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'}</span>
|
||||
</div>
|
||||
${device.manufacturer_id ? `
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Manufacturer ID</span>
|
||||
<span class="signal-advanced-value">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Signal Statistics</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Current RSSI</span>
|
||||
<span class="signal-advanced-value">${device.rssi_current !== null ? device.rssi_current + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Median RSSI</span>
|
||||
<span class="signal-advanced-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Min/Max</span>
|
||||
<span class="signal-advanced-value">${device.rssi_min || 'N/A'} / ${device.rssi_max || 'N/A'} dBm</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Confidence</span>
|
||||
<span class="signal-advanced-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Observation Times</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">First Seen</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.first_seen))}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Last Seen</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.last_seen))}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Seen Count</span>
|
||||
<span class="signal-advanced-value">${device.seen_count} observations</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Rate</span>
|
||||
<span class="signal-advanced-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${device.service_uuids && device.service_uuids.length > 0 ? `
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Service UUIDs</div>
|
||||
<div class="device-uuids">
|
||||
${device.service_uuids.map(uuid => `<span class="device-uuid">${escapeHtml(uuid)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${device.heuristics ? `
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Behavioral Analysis</div>
|
||||
<div class="device-heuristics-detail">
|
||||
${Object.entries(device.heuristics).map(([key, value]) => `
|
||||
<div class="heuristic-item ${value ? 'active' : ''}">
|
||||
<span class="heuristic-name">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
||||
<span class="heuristic-status">${value ? '✓' : '−'}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show device details in modal
|
||||
*/
|
||||
function showDeviceDetails(device) {
|
||||
let modal = document.getElementById('deviceDetailsModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'deviceDetailsModal';
|
||||
modal.className = 'signal-details-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="signal-details-modal-backdrop"></div>
|
||||
<div class="signal-details-modal-content">
|
||||
<div class="signal-details-modal-header">
|
||||
<div class="modal-header-info">
|
||||
<span class="signal-details-modal-title"></span>
|
||||
<span class="signal-details-modal-subtitle"></span>
|
||||
</div>
|
||||
<button class="signal-details-modal-close">×</button>
|
||||
</div>
|
||||
<div class="signal-details-modal-body"></div>
|
||||
<div class="signal-details-modal-footer">
|
||||
<button class="signal-details-copy-btn">Copy JSON</button>
|
||||
<button class="signal-details-copy-addr-btn">Copy Address</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Close handlers
|
||||
modal.querySelector('.signal-details-modal-backdrop').addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
});
|
||||
modal.querySelector('.signal-details-modal-close').addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
});
|
||||
// Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update copy button handlers with current device
|
||||
const copyBtn = modal.querySelector('.signal-details-copy-btn');
|
||||
const copyAddrBtn = modal.querySelector('.signal-details-copy-addr-btn');
|
||||
|
||||
copyBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => {
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy JSON'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
copyAddrBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
copyAddrBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyAddrBtn.textContent = 'Copy Address'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// Populate modal header
|
||||
modal.querySelector('.signal-details-modal-title').textContent = device.name || 'Unknown Device';
|
||||
modal.querySelector('.signal-details-modal-subtitle').textContent = device.address;
|
||||
|
||||
// Populate modal body with enhanced content
|
||||
modal.querySelector('.signal-details-modal-body').innerHTML = createModalContent(device);
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enhanced modal content
|
||||
*/
|
||||
function createModalContent(device) {
|
||||
const protocolLabel = device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth';
|
||||
const sparkline = createSparkline(device.rssi_history, { width: 120, height: 30 });
|
||||
|
||||
return `
|
||||
<div class="modal-device-header">
|
||||
<div class="modal-badges">
|
||||
${createProtocolBadge(device.protocol)}
|
||||
${createHeuristicBadges(device.heuristic_flags)}
|
||||
</div>
|
||||
${createRangeBand(device.range_band, device.range_confidence)}
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Signal Strength</div>
|
||||
<div class="modal-signal-display">
|
||||
<div class="modal-rssi-large">${device.rssi_current !== null ? device.rssi_current : '--'}<span class="rssi-unit">dBm</span></div>
|
||||
<div class="modal-sparkline">${sparkline}</div>
|
||||
</div>
|
||||
<div class="modal-signal-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Median</span>
|
||||
<span class="stat-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Min</span>
|
||||
<span class="stat-value">${device.rssi_min !== null ? device.rssi_min + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Max</span>
|
||||
<span class="stat-value">${device.rssi_max !== null ? device.rssi_max + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Confidence</span>
|
||||
<span class="stat-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Device Information</div>
|
||||
<div class="modal-info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Address</span>
|
||||
<span class="info-value mono">${escapeHtml(device.address)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Address Type</span>
|
||||
<span class="info-value">${escapeHtml(device.address_type)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Protocol</span>
|
||||
<span class="info-value">${protocolLabel}</span>
|
||||
</div>
|
||||
${device.manufacturer_name ? `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Manufacturer</span>
|
||||
<span class="info-value">${escapeHtml(device.manufacturer_name)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${device.manufacturer_id ? `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Manufacturer ID</span>
|
||||
<span class="info-value mono">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Observation Timeline</div>
|
||||
<div class="modal-info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">First Seen</span>
|
||||
<span class="info-value">${formatRelativeTime(device.first_seen)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Last Seen</span>
|
||||
<span class="info-value">${formatRelativeTime(device.last_seen)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Observations</span>
|
||||
<span class="info-value">${device.seen_count}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Rate</span>
|
||||
<span class="info-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${device.service_uuids && device.service_uuids.length > 0 ? `
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Service UUIDs</div>
|
||||
<div class="modal-uuid-list">
|
||||
${device.service_uuids.map(uuid => `<span class="modal-uuid">${escapeHtml(uuid)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${device.heuristics ? `
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Behavioral Analysis</div>
|
||||
<div class="modal-heuristics-grid">
|
||||
${Object.entries(device.heuristics).map(([key, value]) => `
|
||||
<div class="heuristic-check ${value ? 'active' : ''}">
|
||||
<span class="heuristic-indicator">${value ? '✓' : '−'}</span>
|
||||
<span class="heuristic-label">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle advanced panel
|
||||
*/
|
||||
function toggleAdvanced(button) {
|
||||
const card = button.closest('.signal-card');
|
||||
const panel = card.querySelector('.signal-advanced-panel');
|
||||
button.classList.toggle('open');
|
||||
panel.classList.toggle('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy address to clipboard
|
||||
*/
|
||||
function copyAddress(address) {
|
||||
navigator.clipboard.writeText(address).then(() => {
|
||||
if (typeof SignalCards !== 'undefined') {
|
||||
SignalCards.showToast('Address copied');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Investigate device (placeholder for future implementation)
|
||||
*/
|
||||
function investigate(deviceId) {
|
||||
console.log('Investigate device:', deviceId);
|
||||
// Could open service discovery, detailed analysis, etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all device timestamps
|
||||
*/
|
||||
function updateTimestamps(container) {
|
||||
container.querySelectorAll('.device-timestamp[data-timestamp]').forEach(el => {
|
||||
const timestamp = el.dataset.timestamp;
|
||||
if (timestamp) {
|
||||
el.textContent = formatRelativeTime(timestamp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create device filter bar for Bluetooth mode
|
||||
*/
|
||||
function createDeviceFilterBar(container, options = {}) {
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'signal-filter-bar device-filter-bar';
|
||||
filterBar.id = 'btDeviceFilterBar';
|
||||
|
||||
filterBar.innerHTML = `
|
||||
<button class="signal-filter-btn active" data-filter="status" data-value="all">
|
||||
All
|
||||
<span class="signal-filter-count" data-count="all">0</span>
|
||||
</button>
|
||||
<button class="signal-filter-btn" data-filter="status" data-value="new">
|
||||
<span class="filter-dot" style="background: var(--signal-new)"></span>
|
||||
New
|
||||
<span class="signal-filter-count" data-count="new">0</span>
|
||||
</button>
|
||||
<button class="signal-filter-btn" data-filter="status" data-value="baseline">
|
||||
<span class="filter-dot" style="background: var(--signal-baseline)"></span>
|
||||
Known
|
||||
<span class="signal-filter-count" data-count="baseline">0</span>
|
||||
</button>
|
||||
|
||||
<span class="signal-filter-divider"></span>
|
||||
|
||||
<span class="signal-filter-label">Protocol</span>
|
||||
<button class="signal-filter-btn protocol-btn active" data-filter="protocol" data-value="all">All</button>
|
||||
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="ble">BLE</button>
|
||||
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="classic">Classic</button>
|
||||
|
||||
<span class="signal-filter-divider"></span>
|
||||
|
||||
<span class="signal-filter-label">Range</span>
|
||||
<button class="signal-filter-btn range-btn active" data-filter="range" data-value="all">All</button>
|
||||
<button class="signal-filter-btn range-btn" data-filter="range" data-value="close">Close</button>
|
||||
<button class="signal-filter-btn range-btn" data-filter="range" data-value="far">Far</button>
|
||||
|
||||
<div class="signal-search-container">
|
||||
<input type="text" class="signal-search-input" id="btSearchInput" placeholder="Search name or address..." />
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Filter state
|
||||
const filters = { status: 'all', protocol: 'all', range: 'all', search: '' };
|
||||
|
||||
// Apply filters function
|
||||
const applyFilters = () => {
|
||||
const cards = container.querySelectorAll('.device-card');
|
||||
const counts = { all: 0, new: 0, baseline: 0 };
|
||||
|
||||
cards.forEach(card => {
|
||||
const cardStatus = card.dataset.status || 'baseline';
|
||||
const cardProtocol = card.dataset.protocol;
|
||||
const deviceData = JSON.parse(card.dataset.deviceData || '{}');
|
||||
const cardName = (deviceData.name || '').toLowerCase();
|
||||
const cardAddress = (deviceData.address || '').toLowerCase();
|
||||
const cardRange = deviceData.range_band || 'unknown';
|
||||
|
||||
counts.all++;
|
||||
if (cardStatus === 'new') counts.new++;
|
||||
else counts.baseline++;
|
||||
|
||||
// Check filters
|
||||
const statusMatch = filters.status === 'all' || cardStatus === filters.status;
|
||||
const protocolMatch = filters.protocol === 'all' || cardProtocol === filters.protocol;
|
||||
const rangeMatch = filters.range === 'all' ||
|
||||
(filters.range === 'close' && ['very_close', 'close'].includes(cardRange)) ||
|
||||
(filters.range === 'far' && ['nearby', 'far', 'unknown'].includes(cardRange));
|
||||
const searchMatch = !filters.search ||
|
||||
cardName.includes(filters.search) ||
|
||||
cardAddress.includes(filters.search);
|
||||
|
||||
if (statusMatch && protocolMatch && rangeMatch && searchMatch) {
|
||||
card.classList.remove('hidden');
|
||||
} else {
|
||||
card.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Update counts
|
||||
Object.keys(counts).forEach(key => {
|
||||
const badge = filterBar.querySelector(`[data-count="${key}"]`);
|
||||
if (badge) badge.textContent = counts[key];
|
||||
});
|
||||
};
|
||||
|
||||
// Status filter handlers
|
||||
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
filters.status = btn.dataset.value;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
// Protocol filter handlers
|
||||
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
filters.protocol = btn.dataset.value;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
// Range filter handlers
|
||||
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
filters.range = btn.dataset.value;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
// Search handler
|
||||
const searchInput = filterBar.querySelector('#btSearchInput');
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
filters.search = e.target.value.toLowerCase();
|
||||
applyFilters();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
filterBar.applyFilters = applyFilters;
|
||||
return filterBar;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
createDeviceCard,
|
||||
createSparkline,
|
||||
createHeuristicBadges,
|
||||
createRangeBand,
|
||||
createDeviceFilterBar,
|
||||
showDeviceDetails,
|
||||
toggleAdvanced,
|
||||
copyAddress,
|
||||
investigate,
|
||||
updateTimestamps,
|
||||
escapeHtml,
|
||||
formatRelativeTime,
|
||||
RANGE_BANDS,
|
||||
HEURISTIC_BADGES
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.DeviceCard = DeviceCard;
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Message Card Component
|
||||
* Status and alert messages for Bluetooth and TSCM modes
|
||||
*/
|
||||
|
||||
const MessageCard = (function() {
|
||||
'use strict';
|
||||
|
||||
// Message types and their styling
|
||||
const MESSAGE_TYPES = {
|
||||
info: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>`,
|
||||
color: '#3b82f6',
|
||||
bgColor: 'rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
success: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>`,
|
||||
color: '#22c55e',
|
||||
bgColor: 'rgba(34, 197, 94, 0.1)'
|
||||
},
|
||||
warning: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>`,
|
||||
color: '#f59e0b',
|
||||
bgColor: 'rgba(245, 158, 11, 0.1)'
|
||||
},
|
||||
error: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>`,
|
||||
color: '#ef4444',
|
||||
bgColor: 'rgba(239, 68, 68, 0.1)'
|
||||
},
|
||||
scanning: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>`,
|
||||
color: '#06b6d4',
|
||||
bgColor: 'rgba(6, 182, 212, 0.1)'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message card
|
||||
*/
|
||||
function createMessageCard(options) {
|
||||
const {
|
||||
type = 'info',
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
actions,
|
||||
dismissible = true,
|
||||
autoHide = 0,
|
||||
id
|
||||
} = options;
|
||||
|
||||
const config = MESSAGE_TYPES[type] || MESSAGE_TYPES.info;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = `message-card message-card-${type}`;
|
||||
if (id) card.id = id;
|
||||
card.style.setProperty('--message-color', config.color);
|
||||
card.style.setProperty('--message-bg', config.bgColor);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="message-card-icon">
|
||||
${config.icon}
|
||||
</div>
|
||||
<div class="message-card-content">
|
||||
${title ? `<div class="message-card-title">${escapeHtml(title)}</div>` : ''}
|
||||
${message ? `<div class="message-card-text">${escapeHtml(message)}</div>` : ''}
|
||||
${details ? `<div class="message-card-details">${escapeHtml(details)}</div>` : ''}
|
||||
</div>
|
||||
${dismissible ? `
|
||||
<button class="message-card-dismiss" title="Dismiss">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
` : ''}
|
||||
${actions && actions.length > 0 ? `
|
||||
<div class="message-card-actions">
|
||||
${actions.map(action => `
|
||||
<button class="message-action-btn ${action.primary ? 'primary' : ''}"
|
||||
${action.id ? `id="${escapeHtml(action.id)}"` : ''}>
|
||||
${escapeHtml(action.label)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
// Dismiss handler
|
||||
if (dismissible) {
|
||||
card.querySelector('.message-card-dismiss').addEventListener('click', () => {
|
||||
card.classList.add('message-card-hiding');
|
||||
setTimeout(() => card.remove(), 200);
|
||||
});
|
||||
}
|
||||
|
||||
// Action handlers
|
||||
if (actions && actions.length > 0) {
|
||||
actions.forEach(action => {
|
||||
if (action.handler) {
|
||||
const btn = action.id
|
||||
? card.querySelector(`#${action.id}`)
|
||||
: card.querySelector('.message-action-btn');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
action.handler(e, card);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-hide
|
||||
if (autoHide > 0) {
|
||||
setTimeout(() => {
|
||||
if (card.parentElement) {
|
||||
card.classList.add('message-card-hiding');
|
||||
setTimeout(() => card.remove(), 200);
|
||||
}
|
||||
}, autoHide);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scanning status card
|
||||
*/
|
||||
function createScanningCard(options = {}) {
|
||||
const {
|
||||
backend = 'auto',
|
||||
adapter = 'hci0',
|
||||
deviceCount = 0,
|
||||
elapsed = 0,
|
||||
remaining = null
|
||||
} = options;
|
||||
|
||||
return createMessageCard({
|
||||
type: 'scanning',
|
||||
title: 'Scanning for Bluetooth devices...',
|
||||
message: `Backend: ${backend} | Adapter: ${adapter}`,
|
||||
details: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''}` +
|
||||
(remaining !== null ? ` | ${Math.round(remaining)}s remaining` : ''),
|
||||
dismissible: false,
|
||||
id: 'btScanningStatus'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a capability warning card
|
||||
*/
|
||||
function createCapabilityWarning(issues) {
|
||||
if (!issues || issues.length === 0) return null;
|
||||
|
||||
return createMessageCard({
|
||||
type: 'warning',
|
||||
title: 'Bluetooth Capability Issues',
|
||||
message: issues.join('. '),
|
||||
dismissible: true,
|
||||
actions: [
|
||||
{
|
||||
label: 'Retry Check',
|
||||
handler: (e, card) => {
|
||||
card.remove();
|
||||
if (typeof window.checkBtCapabilities === 'function') {
|
||||
window.checkBtCapabilities();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a baseline status card
|
||||
*/
|
||||
function createBaselineCard(deviceCount, isSet = true) {
|
||||
if (isSet) {
|
||||
return createMessageCard({
|
||||
type: 'success',
|
||||
title: 'Baseline Set',
|
||||
message: `${deviceCount} device${deviceCount !== 1 ? 's' : ''} saved as baseline`,
|
||||
details: 'New devices will be highlighted',
|
||||
dismissible: true,
|
||||
autoHide: 5000
|
||||
});
|
||||
} else {
|
||||
return createMessageCard({
|
||||
type: 'info',
|
||||
title: 'No Baseline',
|
||||
message: 'Set a baseline to track new devices',
|
||||
dismissible: true,
|
||||
actions: [
|
||||
{
|
||||
label: 'Set Baseline',
|
||||
primary: true,
|
||||
handler: () => {
|
||||
if (typeof window.setBtBaseline === 'function') {
|
||||
window.setBtBaseline();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scan complete card
|
||||
*/
|
||||
function createScanCompleteCard(deviceCount, duration) {
|
||||
return createMessageCard({
|
||||
type: 'success',
|
||||
title: 'Scan Complete',
|
||||
message: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''} in ${Math.round(duration)}s`,
|
||||
dismissible: true,
|
||||
autoHide: 5000,
|
||||
actions: [
|
||||
{
|
||||
label: 'Export Results',
|
||||
handler: () => {
|
||||
window.open('/api/bluetooth/export?format=csv', '_blank');
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error card
|
||||
*/
|
||||
function createErrorCard(error, retryHandler) {
|
||||
return createMessageCard({
|
||||
type: 'error',
|
||||
title: 'Scan Error',
|
||||
message: error,
|
||||
dismissible: true,
|
||||
actions: retryHandler ? [
|
||||
{
|
||||
label: 'Retry',
|
||||
primary: true,
|
||||
handler: retryHandler
|
||||
}
|
||||
] : []
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a message in a container
|
||||
*/
|
||||
function showMessage(container, options) {
|
||||
const card = createMessageCard(options);
|
||||
container.insertBefore(card, container.firstChild);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message by ID
|
||||
*/
|
||||
function removeMessage(id) {
|
||||
const card = document.getElementById(id);
|
||||
if (card) {
|
||||
card.classList.add('message-card-hiding');
|
||||
setTimeout(() => card.remove(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scanning status
|
||||
*/
|
||||
function updateScanningStatus(options) {
|
||||
const existing = document.getElementById('btScanningStatus');
|
||||
if (existing) {
|
||||
const details = existing.querySelector('.message-card-details');
|
||||
if (details) {
|
||||
details.textContent = `Found ${options.deviceCount} device${options.deviceCount !== 1 ? 's' : ''}` +
|
||||
(options.remaining !== null ? ` | ${Math.round(options.remaining)}s remaining` : '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
createMessageCard,
|
||||
createScanningCard,
|
||||
createCapabilityWarning,
|
||||
createBaselineCard,
|
||||
createScanCompleteCard,
|
||||
createErrorCard,
|
||||
showMessage,
|
||||
removeMessage,
|
||||
updateScanningStatus,
|
||||
MESSAGE_TYPES
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.MessageCard = MessageCard;
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Proximity Radar Component
|
||||
*
|
||||
* SVG-based circular radar visualization for Bluetooth device proximity.
|
||||
* Displays devices positioned by estimated distance with concentric rings
|
||||
* for proximity bands.
|
||||
*/
|
||||
|
||||
const ProximityRadar = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
size: 280,
|
||||
padding: 20,
|
||||
centerRadius: 8,
|
||||
rings: [
|
||||
{ band: 'immediate', radius: 0.25, color: '#22c55e', label: '< 1m' },
|
||||
{ band: 'near', radius: 0.5, color: '#eab308', label: '1-3m' },
|
||||
{ band: 'far', radius: 0.85, color: '#ef4444', label: '3-10m' },
|
||||
],
|
||||
dotMinSize: 4,
|
||||
dotMaxSize: 12,
|
||||
pulseAnimationDuration: 2000,
|
||||
newDeviceThreshold: 30, // seconds
|
||||
};
|
||||
|
||||
// State
|
||||
let container = null;
|
||||
let svg = null;
|
||||
let devices = new Map();
|
||||
let isPaused = false;
|
||||
let activeFilter = null;
|
||||
let onDeviceClick = null;
|
||||
let selectedDeviceKey = null;
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
*/
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('[ProximityRadar] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.onDeviceClick) {
|
||||
onDeviceClick = options.onDeviceClick;
|
||||
}
|
||||
|
||||
createSVG();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the SVG radar structure
|
||||
*/
|
||||
function createSVG() {
|
||||
const size = CONFIG.size;
|
||||
const center = size / 2;
|
||||
|
||||
container.innerHTML = `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="proximity-radar-svg">
|
||||
<defs>
|
||||
<radialGradient id="radarGradient" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="rgba(0, 212, 255, 0.1)" />
|
||||
<stop offset="100%" stop-color="rgba(0, 212, 255, 0)" />
|
||||
</radialGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background gradient -->
|
||||
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"
|
||||
fill="url(#radarGradient)" />
|
||||
|
||||
<!-- Proximity rings -->
|
||||
<g class="radar-rings">
|
||||
${CONFIG.rings.map((ring, i) => {
|
||||
const r = ring.radius * (center - CONFIG.padding);
|
||||
return `
|
||||
<circle cx="${center}" cy="${center}" r="${r}"
|
||||
fill="none" stroke="${ring.color}" stroke-opacity="0.3"
|
||||
stroke-width="1" stroke-dasharray="4,4" />
|
||||
<text x="${center}" y="${center - r + 12}"
|
||||
text-anchor="middle" fill="${ring.color}" fill-opacity="0.6"
|
||||
font-size="9" font-family="monospace">${ring.label}</text>
|
||||
`;
|
||||
}).join('')}
|
||||
</g>
|
||||
|
||||
<!-- Sweep line (animated) -->
|
||||
<line class="radar-sweep" x1="${center}" y1="${center}"
|
||||
x2="${center}" y2="${CONFIG.padding}"
|
||||
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
|
||||
|
||||
<!-- Center point -->
|
||||
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
|
||||
fill="#00d4ff" filter="url(#glow)" />
|
||||
|
||||
<!-- Device dots container -->
|
||||
<g class="radar-devices"></g>
|
||||
|
||||
<!-- Legend -->
|
||||
<g class="radar-legend" transform="translate(${size - 70}, ${size - 55})">
|
||||
<text x="0" y="0" fill="#666" font-size="8">PROXIMITY</text>
|
||||
<text x="0" y="0" fill="#666" font-size="7" font-style="italic"
|
||||
transform="translate(0, 10)">(signal strength)</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
svg = container.querySelector('svg');
|
||||
|
||||
// Add sweep animation
|
||||
animateSweep();
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the radar sweep line
|
||||
*/
|
||||
function animateSweep() {
|
||||
const sweepLine = svg.querySelector('.radar-sweep');
|
||||
if (!sweepLine) return;
|
||||
|
||||
let angle = 0;
|
||||
const center = CONFIG.size / 2;
|
||||
|
||||
function rotate() {
|
||||
if (isPaused) {
|
||||
requestAnimationFrame(rotate);
|
||||
return;
|
||||
}
|
||||
|
||||
angle = (angle + 1) % 360;
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const radius = center - CONFIG.padding;
|
||||
const x2 = center + Math.sin(rad) * radius;
|
||||
const y2 = center - Math.cos(rad) * radius;
|
||||
|
||||
sweepLine.setAttribute('x2', x2);
|
||||
sweepLine.setAttribute('y2', y2);
|
||||
|
||||
requestAnimationFrame(rotate);
|
||||
}
|
||||
|
||||
requestAnimationFrame(rotate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update devices on the radar
|
||||
*/
|
||||
function updateDevices(deviceList) {
|
||||
if (isPaused) return;
|
||||
|
||||
// Update device map
|
||||
deviceList.forEach(device => {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Apply filter and render
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render device dots on the radar
|
||||
*/
|
||||
function renderDevices() {
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
if (!devicesGroup) return;
|
||||
|
||||
const center = CONFIG.size / 2;
|
||||
const maxRadius = center - CONFIG.padding;
|
||||
|
||||
// Filter devices
|
||||
let visibleDevices = Array.from(devices.values());
|
||||
|
||||
if (activeFilter === 'newOnly') {
|
||||
visibleDevices = visibleDevices.filter(d => d.is_new || d.age_seconds < CONFIG.newDeviceThreshold);
|
||||
} else if (activeFilter === 'strongest') {
|
||||
visibleDevices = visibleDevices
|
||||
.filter(d => d.rssi_current != null)
|
||||
.sort((a, b) => (b.rssi_current || -100) - (a.rssi_current || -100))
|
||||
.slice(0, 10);
|
||||
} else if (activeFilter === 'unapproved') {
|
||||
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
|
||||
}
|
||||
|
||||
// Build SVG for each device
|
||||
const dots = visibleDevices.map(device => {
|
||||
// Calculate position
|
||||
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
|
||||
|
||||
// Calculate dot size based on confidence
|
||||
const confidence = device.distance_confidence || 0.5;
|
||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||
|
||||
// Get color based on proximity band
|
||||
const color = getBandColor(device.proximity_band);
|
||||
|
||||
// Check if newly seen (pulse animation)
|
||||
const isNew = device.age_seconds < 5;
|
||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
||||
|
||||
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>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
|
||||
// Attach click handlers
|
||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const deviceKey = el.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate device position on radar
|
||||
*/
|
||||
function calculateDevicePosition(device, center, maxRadius) {
|
||||
// Calculate radius based on proximity band/distance
|
||||
let radiusRatio;
|
||||
const band = device.proximity_band || 'unknown';
|
||||
|
||||
if (device.estimated_distance_m != null) {
|
||||
// Use actual distance (log scale)
|
||||
const maxDistance = 15;
|
||||
radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1));
|
||||
} else {
|
||||
// Use band-based positioning
|
||||
switch (band) {
|
||||
case 'immediate': radiusRatio = 0.15; break;
|
||||
case 'near': radiusRatio = 0.4; break;
|
||||
case 'far': radiusRatio = 0.7; break;
|
||||
default: radiusRatio = 0.9; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate angle based on device key hash (stable positioning)
|
||||
const angle = hashToAngle(device.device_key || device.device_id);
|
||||
const radius = radiusRatio * maxRadius;
|
||||
|
||||
const x = center + Math.sin(angle) * radius;
|
||||
const y = center - Math.cos(angle) * radius;
|
||||
|
||||
return { x, y, radius };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash string to angle for stable positioning
|
||||
*/
|
||||
function hashToAngle(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return (Math.abs(hash) % 360) * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for proximity band
|
||||
*/
|
||||
function getBandColor(band) {
|
||||
switch (band) {
|
||||
case 'immediate': return '#22c55e';
|
||||
case 'near': return '#eab308';
|
||||
case 'far': return '#ef4444';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter mode
|
||||
*/
|
||||
function setFilter(filter) {
|
||||
activeFilter = filter === activeFilter ? null : filter;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all devices
|
||||
*/
|
||||
function clear() {
|
||||
devices.clear();
|
||||
selectedDeviceKey = null;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a specific device on the radar
|
||||
*/
|
||||
function highlightDevice(deviceKey) {
|
||||
selectedDeviceKey = deviceKey;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device highlighting
|
||||
*/
|
||||
function clearHighlight() {
|
||||
selectedDeviceKey = null;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get zone counts
|
||||
*/
|
||||
function getZoneCounts() {
|
||||
const counts = { immediate: 0, near: 0, far: 0, unknown: 0 };
|
||||
devices.forEach(device => {
|
||||
const band = device.proximity_band || 'unknown';
|
||||
if (counts.hasOwnProperty(band)) {
|
||||
counts[band]++;
|
||||
} else {
|
||||
counts.unknown++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe rendering
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
updateDevices,
|
||||
setFilter,
|
||||
setPaused,
|
||||
clear,
|
||||
getZoneCounts,
|
||||
highlightDevice,
|
||||
clearHighlight,
|
||||
isPaused: () => isPaused,
|
||||
getFilter: () => activeFilter,
|
||||
getSelectedDevice: () => selectedDeviceKey,
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ProximityRadar;
|
||||
}
|
||||
|
||||
window.ProximityRadar = ProximityRadar;
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* RSSI Sparkline Component
|
||||
* SVG-based real-time RSSI visualization
|
||||
*/
|
||||
|
||||
const RSSISparkline = (function() {
|
||||
'use strict';
|
||||
|
||||
// Default configuration
|
||||
const DEFAULT_CONFIG = {
|
||||
width: 80,
|
||||
height: 24,
|
||||
maxSamples: 30,
|
||||
strokeWidth: 1.5,
|
||||
minRssi: -100,
|
||||
maxRssi: -30,
|
||||
showCurrentValue: true,
|
||||
showGradient: true,
|
||||
animateUpdates: true
|
||||
};
|
||||
|
||||
// Color thresholds based on RSSI
|
||||
const RSSI_COLORS = {
|
||||
excellent: { rssi: -50, color: '#22c55e' }, // Green
|
||||
good: { rssi: -60, color: '#84cc16' }, // Lime
|
||||
fair: { rssi: -70, color: '#eab308' }, // Yellow
|
||||
weak: { rssi: -80, color: '#f97316' }, // Orange
|
||||
poor: { rssi: -100, color: '#ef4444' } // Red
|
||||
};
|
||||
|
||||
/**
|
||||
* Get color for RSSI value
|
||||
*/
|
||||
function getRssiColor(rssi) {
|
||||
if (rssi >= RSSI_COLORS.excellent.rssi) return RSSI_COLORS.excellent.color;
|
||||
if (rssi >= RSSI_COLORS.good.rssi) return RSSI_COLORS.good.color;
|
||||
if (rssi >= RSSI_COLORS.fair.rssi) return RSSI_COLORS.fair.color;
|
||||
if (rssi >= RSSI_COLORS.weak.rssi) return RSSI_COLORS.weak.color;
|
||||
return RSSI_COLORS.poor.color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize RSSI value to 0-1 range
|
||||
*/
|
||||
function normalizeRssi(rssi, min, max) {
|
||||
return Math.max(0, Math.min(1, (rssi - min) / (max - min)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sparkline SVG element
|
||||
*/
|
||||
function createSparklineSvg(samples, config = {}) {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
const { width, height, minRssi, maxRssi, strokeWidth, showGradient } = cfg;
|
||||
|
||||
if (!samples || samples.length < 2) {
|
||||
return createEmptySparkline(width, height);
|
||||
}
|
||||
|
||||
// Normalize samples
|
||||
const normalized = samples.map(s => {
|
||||
const rssi = typeof s === 'object' ? s.rssi : s;
|
||||
return {
|
||||
value: normalizeRssi(rssi, minRssi, maxRssi),
|
||||
rssi: rssi
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate path
|
||||
const stepX = width / (normalized.length - 1);
|
||||
let pathD = '';
|
||||
let areaD = '';
|
||||
const points = [];
|
||||
|
||||
normalized.forEach((sample, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (sample.value * (height - 2)) - 1; // 1px padding top/bottom
|
||||
points.push({ x, y, rssi: sample.rssi });
|
||||
|
||||
if (i === 0) {
|
||||
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
} else {
|
||||
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Close area path
|
||||
areaD += ` L${width},${height} Z`;
|
||||
|
||||
// Get current color based on latest value
|
||||
const latestRssi = normalized[normalized.length - 1].rssi;
|
||||
const strokeColor = getRssiColor(latestRssi);
|
||||
|
||||
// Create SVG
|
||||
const gradientId = `sparkline-gradient-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
let gradientDef = '';
|
||||
if (showGradient) {
|
||||
gradientDef = `
|
||||
<defs>
|
||||
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
|
||||
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg class="rssi-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
${gradientDef}
|
||||
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
|
||||
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
|
||||
r="2" fill="${strokeColor}" class="sparkline-dot" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty sparkline placeholder
|
||||
*/
|
||||
function createEmptySparkline(width, height) {
|
||||
return `
|
||||
<svg class="rssi-sparkline-svg rssi-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
|
||||
stroke="#444" stroke-width="1" stroke-dasharray="2,2" />
|
||||
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
|
||||
fill="#666" font-size="8" font-family="monospace">No data</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a live sparkline component with update capability
|
||||
*/
|
||||
class LiveSparkline {
|
||||
constructor(container, config = {}) {
|
||||
this.container = typeof container === 'string'
|
||||
? document.querySelector(container)
|
||||
: container;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.samples = [];
|
||||
this.animationFrame = null;
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
addSample(rssi) {
|
||||
this.samples.push({
|
||||
rssi: rssi,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Limit samples
|
||||
if (this.samples.length > this.config.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
setSamples(samples) {
|
||||
this.samples = samples.slice(-this.config.maxSamples);
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
const svg = createSparklineSvg(this.samples, this.config);
|
||||
this.container.innerHTML = svg;
|
||||
|
||||
// Add current value display if enabled
|
||||
if (this.config.showCurrentValue && this.samples.length > 0) {
|
||||
const latest = this.samples[this.samples.length - 1];
|
||||
const rssi = typeof latest === 'object' ? latest.rssi : latest;
|
||||
const valueEl = document.createElement('span');
|
||||
valueEl.className = 'rssi-current-value';
|
||||
valueEl.textContent = `${rssi} dBm`;
|
||||
valueEl.style.color = getRssiColor(rssi);
|
||||
this.container.appendChild(valueEl);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.samples = [];
|
||||
this.render();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create inline sparkline HTML (for use in templates)
|
||||
*/
|
||||
function createInlineSparkline(rssiHistory, options = {}) {
|
||||
const samples = rssiHistory.map(h => typeof h === 'object' ? h.rssi : h);
|
||||
return createSparklineSvg(samples, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sparkline with value display
|
||||
*/
|
||||
function createSparklineWithValue(rssiHistory, currentRssi, options = {}) {
|
||||
const { width = 60, height = 20 } = options;
|
||||
const svg = createInlineSparkline(rssiHistory, { ...options, width, height });
|
||||
const color = getRssiColor(currentRssi);
|
||||
|
||||
return `
|
||||
<div class="rssi-sparkline-wrapper">
|
||||
${svg}
|
||||
<span class="rssi-value" style="color: ${color}">${currentRssi !== null ? currentRssi : '--'} dBm</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
createSparklineSvg,
|
||||
createInlineSparkline,
|
||||
createSparklineWithValue,
|
||||
createEmptySparkline,
|
||||
LiveSparkline,
|
||||
getRssiColor,
|
||||
normalizeRssi,
|
||||
DEFAULT_CONFIG,
|
||||
RSSI_COLORS
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.RSSISparkline = RSSISparkline;
|
||||
@@ -0,0 +1,905 @@
|
||||
/**
|
||||
* Signal Activity Timeline Component
|
||||
* Lightweight visualization for RF signal presence over time
|
||||
* Used for TSCM sweeps and investigative analysis
|
||||
*/
|
||||
|
||||
const SignalTimeline = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
timeWindows: {
|
||||
'5m': 5 * 60 * 1000,
|
||||
'15m': 15 * 60 * 1000,
|
||||
'30m': 30 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'2h': 2 * 60 * 60 * 1000
|
||||
},
|
||||
defaultWindow: '30m',
|
||||
maxSignals: 100, // max signals to track in memory
|
||||
maxDisplayedLanes: 15, // max lanes to show at once (scroll for more)
|
||||
burstThreshold: 5, // messages in burst window = burst
|
||||
burstWindow: 60 * 1000, // 1 minute
|
||||
updateInterval: 5000, // refresh every 5 seconds
|
||||
barMinWidth: 2 // minimum bar width in pixels
|
||||
};
|
||||
|
||||
// State
|
||||
const state = {
|
||||
signals: new Map(), // frequency -> signal data
|
||||
annotations: [],
|
||||
filters: {
|
||||
hideBaseline: false,
|
||||
showOnlyNew: false,
|
||||
showOnlyBurst: false
|
||||
},
|
||||
timeWindow: config.defaultWindow,
|
||||
tooltip: null,
|
||||
updateTimer: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Signal data structure
|
||||
*/
|
||||
function createSignal(frequency, name = null) {
|
||||
return {
|
||||
frequency: frequency,
|
||||
name: name || categorizeFrequency(frequency),
|
||||
events: [], // { timestamp, strength, duration }
|
||||
firstSeen: null,
|
||||
lastSeen: null,
|
||||
status: 'new', // new, baseline, burst, flagged, gone
|
||||
pattern: null, // detected pattern description
|
||||
flagged: false,
|
||||
transmissionCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize frequency into human-readable name
|
||||
*/
|
||||
function categorizeFrequency(freq) {
|
||||
const f = parseFloat(freq);
|
||||
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
||||
if (f >= 5150 && f <= 5850) return '5 GHz wireless band';
|
||||
if (f >= 433 && f <= 434) return '433 MHz low-power band';
|
||||
if (f >= 868 && f <= 869) return '868 MHz low-power band';
|
||||
if (f >= 902 && f <= 928) return '915 MHz low-power band';
|
||||
if (f >= 315 && f <= 316) return '315MHz';
|
||||
if (f >= 2402 && f <= 2480) return 'Bluetooth band';
|
||||
if (f >= 144 && f <= 148) return 'VHF amateur band';
|
||||
if (f >= 420 && f <= 450) return 'UHF amateur band';
|
||||
return `${freq} MHz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a signal event
|
||||
*/
|
||||
function addEvent(frequency, strength = 3, duration = 1000, name = null) {
|
||||
const now = Date.now();
|
||||
let signal = state.signals.get(frequency);
|
||||
|
||||
if (!signal) {
|
||||
signal = createSignal(frequency, name);
|
||||
signal.firstSeen = now;
|
||||
state.signals.set(frequency, signal);
|
||||
|
||||
// Add annotation for new signal
|
||||
addAnnotation('new', `New signal observed: ${signal.name}`, now);
|
||||
}
|
||||
|
||||
// Add event
|
||||
signal.events.push({
|
||||
timestamp: now,
|
||||
strength: Math.min(5, Math.max(1, strength)),
|
||||
duration: duration
|
||||
});
|
||||
|
||||
signal.lastSeen = now;
|
||||
signal.transmissionCount++;
|
||||
|
||||
// Update status
|
||||
updateSignalStatus(signal);
|
||||
|
||||
// Detect patterns
|
||||
detectPatterns(signal);
|
||||
|
||||
// Limit events to prevent memory bloat
|
||||
const windowMs = config.timeWindows['2h'];
|
||||
signal.events = signal.events.filter(e => now - e.timestamp < windowMs);
|
||||
|
||||
// Prune old signals if we exceed max
|
||||
if (state.signals.size > config.maxSignals) {
|
||||
pruneOldSignals();
|
||||
}
|
||||
|
||||
return signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove oldest/least active signals to stay under limit
|
||||
*/
|
||||
function pruneOldSignals() {
|
||||
const signals = Array.from(state.signals.entries());
|
||||
// Sort by last seen (oldest first), but keep flagged signals
|
||||
signals.sort((a, b) => {
|
||||
if (a[1].flagged && !b[1].flagged) return 1;
|
||||
if (!a[1].flagged && b[1].flagged) return -1;
|
||||
return a[1].lastSeen - b[1].lastSeen;
|
||||
});
|
||||
|
||||
// Remove oldest signals until under limit
|
||||
const toRemove = signals.length - config.maxSignals;
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
if (!signals[i][1].flagged) {
|
||||
state.signals.delete(signals[i][0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signal status based on activity
|
||||
*/
|
||||
function updateSignalStatus(signal) {
|
||||
const now = Date.now();
|
||||
const recentEvents = signal.events.filter(
|
||||
e => now - e.timestamp < config.burstWindow
|
||||
);
|
||||
|
||||
// Check for burst activity
|
||||
if (recentEvents.length >= config.burstThreshold) {
|
||||
if (signal.status !== 'burst') {
|
||||
signal.status = 'burst';
|
||||
addAnnotation('burst',
|
||||
`Activity cluster: ${recentEvents.length} events in ${config.burstWindow/1000}s - ${signal.name}`,
|
||||
now
|
||||
);
|
||||
}
|
||||
} else if (signal.transmissionCount >= 20) {
|
||||
// Baseline if seen many times
|
||||
signal.status = 'baseline';
|
||||
} else if (now - signal.firstSeen < 5 * 60 * 1000) {
|
||||
// New if first seen within 5 minutes
|
||||
signal.status = 'new';
|
||||
}
|
||||
|
||||
// Override if flagged
|
||||
if (signal.flagged) {
|
||||
signal.status = 'flagged';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect repeating patterns in signal events
|
||||
*/
|
||||
function detectPatterns(signal) {
|
||||
if (signal.events.length < 4) return;
|
||||
|
||||
// Get intervals between events
|
||||
const intervals = [];
|
||||
for (let i = 1; i < signal.events.length; i++) {
|
||||
intervals.push(signal.events[i].timestamp - signal.events[i-1].timestamp);
|
||||
}
|
||||
|
||||
// Look for consistent interval (within 10% tolerance)
|
||||
if (intervals.length >= 3) {
|
||||
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const tolerance = avgInterval * 0.1;
|
||||
const consistent = intervals.filter(
|
||||
i => Math.abs(i - avgInterval) <= tolerance
|
||||
).length;
|
||||
|
||||
if (consistent >= intervals.length * 0.7) {
|
||||
const seconds = Math.round(avgInterval / 1000);
|
||||
if (seconds >= 1 && seconds <= 3600) {
|
||||
const patternStr = seconds < 60
|
||||
? `${seconds}s interval`
|
||||
: `${Math.round(seconds/60)}m interval`;
|
||||
|
||||
if (signal.pattern !== patternStr) {
|
||||
signal.pattern = patternStr;
|
||||
addAnnotation('pattern',
|
||||
`Repeating pattern observed: ${patternStr} - ${signal.name}`,
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add annotation
|
||||
*/
|
||||
function addAnnotation(type, message, timestamp) {
|
||||
state.annotations.unshift({
|
||||
type: type,
|
||||
message: message,
|
||||
timestamp: timestamp
|
||||
});
|
||||
|
||||
// Limit annotations
|
||||
if (state.annotations.length > 20) {
|
||||
state.annotations.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag a signal for investigation
|
||||
*/
|
||||
function flagSignal(frequency) {
|
||||
const signal = state.signals.get(frequency);
|
||||
if (signal) {
|
||||
signal.flagged = !signal.flagged;
|
||||
signal.status = signal.flagged ? 'flagged' : 'new';
|
||||
addAnnotation('flagged',
|
||||
signal.flagged
|
||||
? `Marked for review: ${signal.name}`
|
||||
: `Review mark removed: ${signal.name}`,
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark signal as gone (no longer transmitting)
|
||||
*/
|
||||
function markGone(frequency) {
|
||||
const signal = state.signals.get(frequency);
|
||||
if (signal && signal.status !== 'gone') {
|
||||
signal.status = 'gone';
|
||||
addAnnotation('gone', `Signal no longer observed: ${signal.name}`, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the timeline DOM element
|
||||
*/
|
||||
function createTimeline(containerId, options = {}) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
|
||||
const startCollapsed = options.collapsed !== false;
|
||||
|
||||
const timeline = document.createElement('div');
|
||||
timeline.className = 'signal-timeline' + (startCollapsed ? ' collapsed' : '');
|
||||
timeline.id = 'signalTimeline';
|
||||
|
||||
timeline.innerHTML = `
|
||||
<div class="signal-timeline-header" id="timelineHeader">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<span class="signal-timeline-collapse-icon">▼</span>
|
||||
<span class="signal-timeline-title">Signal Activity Timeline</span>
|
||||
</div>
|
||||
<div class="signal-timeline-header-stats" id="timelineHeaderStats">
|
||||
<div class="signal-timeline-header-stat">
|
||||
<span class="stat-value" id="timelineStatTotal">0</span>
|
||||
<span>signals</span>
|
||||
</div>
|
||||
<div class="signal-timeline-header-stat">
|
||||
<span class="stat-value" id="timelineStatNew">0</span>
|
||||
<span>new</span>
|
||||
</div>
|
||||
<div class="signal-timeline-header-stat">
|
||||
<span class="stat-value" id="timelineStatBurst">0</span>
|
||||
<span>burst</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-timeline-body">
|
||||
<div class="signal-timeline-controls" style="display: flex; align-items: center; gap: 6px; padding: 8px 0; flex-wrap: wrap;">
|
||||
<button class="signal-timeline-btn" data-filter="hideBaseline" title="Hide baseline signals">
|
||||
Hide Known
|
||||
</button>
|
||||
<button class="signal-timeline-btn" data-filter="showOnlyNew" title="Show only new signals">
|
||||
New Only
|
||||
</button>
|
||||
<button class="signal-timeline-btn" data-filter="showOnlyBurst" title="Show only burst activity">
|
||||
Bursts
|
||||
</button>
|
||||
<div class="signal-timeline-window" style="margin-left: auto;">
|
||||
<span>Window:</span>
|
||||
<select id="timelineWindowSelect">
|
||||
<option value="5m">5 min</option>
|
||||
<option value="15m">15 min</option>
|
||||
<option value="30m" selected>30 min</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="2h">2 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-timeline-axis" id="timelineAxis"></div>
|
||||
<div class="signal-timeline-lanes" id="timelineLanes">
|
||||
<div class="signal-timeline-empty">
|
||||
<div class="signal-timeline-empty-icon">📡</div>
|
||||
<div>No signal activity recorded</div>
|
||||
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as signals are observed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-timeline-annotations" id="timelineAnnotations" style="display: none;"></div>
|
||||
<div class="signal-timeline-legend">
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot new"></div>
|
||||
<span>New</span>
|
||||
</div>
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot baseline"></div>
|
||||
<span>Baseline</span>
|
||||
</div>
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot burst"></div>
|
||||
<span>Burst</span>
|
||||
</div>
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot flagged"></div>
|
||||
<span>Flagged</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(timeline);
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners(timeline);
|
||||
|
||||
// Create tooltip element
|
||||
createTooltip();
|
||||
|
||||
// Start update timer
|
||||
startUpdateTimer();
|
||||
|
||||
// Initial render
|
||||
render();
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners
|
||||
*/
|
||||
function setupEventListeners(timeline) {
|
||||
// Collapse toggle
|
||||
const header = timeline.querySelector('#timelineHeader');
|
||||
if (header) {
|
||||
header.addEventListener('click', (e) => {
|
||||
// Don't toggle if clicking on controls inside header
|
||||
if (e.target.closest('button') || e.target.closest('select')) return;
|
||||
timeline.classList.toggle('collapsed');
|
||||
});
|
||||
}
|
||||
|
||||
// Filter buttons
|
||||
timeline.querySelectorAll('.signal-timeline-btn[data-filter]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Prevent collapse toggle
|
||||
const filter = btn.dataset.filter;
|
||||
state.filters[filter] = !state.filters[filter];
|
||||
btn.classList.toggle('active', state.filters[filter]);
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
// Time window selector
|
||||
const windowSelect = timeline.querySelector('#timelineWindowSelect');
|
||||
if (windowSelect) {
|
||||
windowSelect.addEventListener('click', (e) => e.stopPropagation());
|
||||
windowSelect.addEventListener('change', (e) => {
|
||||
state.timeWindow = e.target.value;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
// Lane click to expand
|
||||
timeline.addEventListener('click', (e) => {
|
||||
const lane = e.target.closest('.signal-timeline-lane');
|
||||
if (lane && !e.target.closest('button')) {
|
||||
lane.classList.toggle('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
// Lane right-click to flag
|
||||
timeline.addEventListener('contextmenu', (e) => {
|
||||
const lane = e.target.closest('.signal-timeline-lane');
|
||||
if (lane) {
|
||||
e.preventDefault();
|
||||
const freq = lane.dataset.frequency;
|
||||
flagSignal(freq);
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tooltip element
|
||||
*/
|
||||
function createTooltip() {
|
||||
if (state.tooltip) return;
|
||||
|
||||
state.tooltip = document.createElement('div');
|
||||
state.tooltip.className = 'signal-timeline-tooltip';
|
||||
state.tooltip.style.display = 'none';
|
||||
document.body.appendChild(state.tooltip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tooltip
|
||||
*/
|
||||
function showTooltip(e, signal) {
|
||||
if (!state.tooltip) return;
|
||||
|
||||
const now = Date.now();
|
||||
const duration = signal.lastSeen - signal.firstSeen;
|
||||
const durationStr = formatDuration(duration);
|
||||
const lastSeenStr = formatTimeAgo(signal.lastSeen);
|
||||
|
||||
state.tooltip.innerHTML = `
|
||||
<div class="signal-timeline-tooltip-header">${signal.name}</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Frequency:</span>
|
||||
<span>${signal.frequency} MHz</span>
|
||||
</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>First seen:</span>
|
||||
<span>${formatTime(signal.firstSeen)}</span>
|
||||
</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Last seen:</span>
|
||||
<span>${lastSeenStr}</span>
|
||||
</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Transmissions:</span>
|
||||
<span>${signal.transmissionCount}</span>
|
||||
</div>
|
||||
${signal.pattern ? `
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Pattern:</span>
|
||||
<span>${signal.pattern}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Status:</span>
|
||||
<span style="text-transform: capitalize;">${signal.status}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
state.tooltip.style.display = 'block';
|
||||
state.tooltip.style.left = (e.clientX + 10) + 'px';
|
||||
state.tooltip.style.top = (e.clientY + 10) + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide tooltip
|
||||
*/
|
||||
function hideTooltip() {
|
||||
if (state.tooltip) {
|
||||
state.tooltip.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the update timer
|
||||
*/
|
||||
function startUpdateTimer() {
|
||||
if (state.updateTimer) {
|
||||
clearInterval(state.updateTimer);
|
||||
}
|
||||
state.updateTimer = setInterval(() => {
|
||||
render();
|
||||
}, config.updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the update timer
|
||||
*/
|
||||
function stopUpdateTimer() {
|
||||
if (state.updateTimer) {
|
||||
clearInterval(state.updateTimer);
|
||||
state.updateTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the timeline
|
||||
*/
|
||||
function render() {
|
||||
const lanesContainer = document.getElementById('timelineLanes');
|
||||
const axisContainer = document.getElementById('timelineAxis');
|
||||
const annotationsContainer = document.getElementById('timelineAnnotations');
|
||||
|
||||
if (!lanesContainer) return;
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = config.timeWindows[state.timeWindow];
|
||||
const startTime = now - windowMs;
|
||||
|
||||
// Render time axis
|
||||
renderAxis(axisContainer, startTime, now, windowMs);
|
||||
|
||||
// Get filtered signals
|
||||
let signals = Array.from(state.signals.values());
|
||||
|
||||
// Apply filters
|
||||
if (state.filters.hideBaseline) {
|
||||
signals = signals.filter(s => s.status !== 'baseline');
|
||||
}
|
||||
if (state.filters.showOnlyNew) {
|
||||
signals = signals.filter(s => s.status === 'new');
|
||||
}
|
||||
if (state.filters.showOnlyBurst) {
|
||||
signals = signals.filter(s => s.status === 'burst');
|
||||
}
|
||||
|
||||
// Sort by last seen (most recent first), then by status priority
|
||||
const statusPriority = { flagged: 0, burst: 1, new: 2, baseline: 3, gone: 4 };
|
||||
signals.sort((a, b) => {
|
||||
const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return b.lastSeen - a.lastSeen;
|
||||
});
|
||||
|
||||
// Render lanes (limit displayed for performance)
|
||||
const totalSignals = signals.length;
|
||||
const displayedSignals = signals.slice(0, config.maxDisplayedLanes);
|
||||
const hiddenCount = totalSignals - displayedSignals.length;
|
||||
|
||||
if (signals.length === 0) {
|
||||
lanesContainer.innerHTML = `
|
||||
<div class="signal-timeline-empty">
|
||||
<div class="signal-timeline-empty-icon">📡</div>
|
||||
<div>No signal activity recorded</div>
|
||||
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as signals are observed</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
let html = displayedSignals.map(signal =>
|
||||
renderLane(signal, startTime, now, windowMs)
|
||||
).join('');
|
||||
|
||||
// Show indicator if there are more signals
|
||||
if (hiddenCount > 0) {
|
||||
html += `
|
||||
<div class="signal-timeline-more" style="text-align: center; padding: 8px; font-size: 10px; color: var(--text-dim, #666);">
|
||||
+${hiddenCount} more signals (scroll or adjust filters)
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
lanesContainer.innerHTML = html;
|
||||
|
||||
// Add event listeners to new lanes
|
||||
lanesContainer.querySelectorAll('.signal-timeline-lane').forEach(lane => {
|
||||
const freq = lane.dataset.frequency;
|
||||
const signal = state.signals.get(freq);
|
||||
|
||||
lane.addEventListener('mouseenter', (e) => showTooltip(e, signal));
|
||||
lane.addEventListener('mousemove', (e) => showTooltip(e, signal));
|
||||
lane.addEventListener('mouseleave', hideTooltip);
|
||||
});
|
||||
}
|
||||
|
||||
// Update header stats
|
||||
const allSignals = Array.from(state.signals.values());
|
||||
const statTotal = document.getElementById('timelineStatTotal');
|
||||
const statNew = document.getElementById('timelineStatNew');
|
||||
const statBurst = document.getElementById('timelineStatBurst');
|
||||
if (statTotal) statTotal.textContent = allSignals.length;
|
||||
if (statNew) statNew.textContent = allSignals.filter(s => s.status === 'new').length;
|
||||
if (statBurst) statBurst.textContent = allSignals.filter(s => s.status === 'burst').length;
|
||||
|
||||
// Render annotations
|
||||
renderAnnotations(annotationsContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render time axis
|
||||
*/
|
||||
function renderAxis(container, startTime, endTime, windowMs) {
|
||||
if (!container) return;
|
||||
|
||||
const labels = [];
|
||||
const steps = 6;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const time = startTime + (windowMs * i / steps);
|
||||
const label = i === steps ? 'Now' : formatTimeShort(time);
|
||||
labels.push(`<span class="signal-timeline-axis-label">${label}</span>`);
|
||||
}
|
||||
|
||||
container.innerHTML = labels.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single lane
|
||||
*/
|
||||
function renderLane(signal, startTime, endTime, windowMs) {
|
||||
const isBaseline = signal.status === 'baseline';
|
||||
|
||||
// Get events within time window
|
||||
const visibleEvents = signal.events.filter(
|
||||
e => e.timestamp >= startTime && e.timestamp <= endTime
|
||||
);
|
||||
|
||||
// Generate bars HTML
|
||||
const barsHtml = aggregateAndRenderBars(visibleEvents, startTime, windowMs);
|
||||
|
||||
// Generate ticks for expanded view
|
||||
const ticksHtml = visibleEvents.map(event => {
|
||||
const position = ((event.timestamp - startTime) / windowMs) * 100;
|
||||
return `<div class="signal-timeline-tick"
|
||||
style="left: ${position}%;"
|
||||
data-strength="${event.strength}"></div>`;
|
||||
}).join('');
|
||||
|
||||
// Stats
|
||||
const recentCount = visibleEvents.length;
|
||||
|
||||
return `
|
||||
<div class="signal-timeline-lane ${isBaseline ? 'baseline' : ''}"
|
||||
data-frequency="${signal.frequency}"
|
||||
data-status="${signal.status}">
|
||||
<div class="signal-timeline-status" data-status="${signal.status}"></div>
|
||||
<div class="signal-timeline-label">
|
||||
<span class="signal-timeline-freq">${signal.frequency}</span>
|
||||
<span class="signal-timeline-name">${signal.name}</span>
|
||||
</div>
|
||||
<div class="signal-timeline-track">
|
||||
<div class="signal-timeline-track-bg">
|
||||
${barsHtml}
|
||||
</div>
|
||||
<div class="signal-timeline-ticks">
|
||||
${ticksHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-timeline-stats">
|
||||
<span class="signal-timeline-stat-count">${recentCount}</span>
|
||||
<span class="signal-timeline-stat-label">events</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate events into bars and render
|
||||
*/
|
||||
function aggregateAndRenderBars(events, startTime, windowMs) {
|
||||
if (events.length === 0) return '';
|
||||
|
||||
// Group nearby events into bars
|
||||
const bars = [];
|
||||
let currentBar = null;
|
||||
const minGap = windowMs / 100; // Merge events within 1% of window
|
||||
|
||||
events.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
for (const event of events) {
|
||||
if (!currentBar) {
|
||||
currentBar = {
|
||||
start: event.timestamp,
|
||||
end: event.timestamp + event.duration,
|
||||
maxStrength: event.strength,
|
||||
count: 1
|
||||
};
|
||||
} else if (event.timestamp - currentBar.end <= minGap) {
|
||||
// Extend current bar
|
||||
currentBar.end = Math.max(currentBar.end, event.timestamp + event.duration);
|
||||
currentBar.maxStrength = Math.max(currentBar.maxStrength, event.strength);
|
||||
currentBar.count++;
|
||||
} else {
|
||||
// Start new bar
|
||||
bars.push(currentBar);
|
||||
currentBar = {
|
||||
start: event.timestamp,
|
||||
end: event.timestamp + event.duration,
|
||||
maxStrength: event.strength,
|
||||
count: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
if (currentBar) bars.push(currentBar);
|
||||
|
||||
// Determine status for bars based on count
|
||||
return bars.map(bar => {
|
||||
const left = ((bar.start - startTime) / windowMs) * 100;
|
||||
const width = Math.max(
|
||||
config.barMinWidth / 8, // Convert px to approximate %
|
||||
((bar.end - bar.start) / windowMs) * 100
|
||||
);
|
||||
const status = bar.count >= config.burstThreshold ? 'burst' :
|
||||
bar.count > 1 ? 'repeated' : 'new';
|
||||
|
||||
return `<div class="signal-timeline-bar"
|
||||
style="left: ${left}%; width: ${width}%;"
|
||||
data-strength="${bar.maxStrength}"
|
||||
data-status="${status}"></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render annotations
|
||||
*/
|
||||
function renderAnnotations(container) {
|
||||
if (!container) return;
|
||||
|
||||
const recentAnnotations = state.annotations.slice(0, 5);
|
||||
|
||||
if (recentAnnotations.length === 0) {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = recentAnnotations.map(ann => {
|
||||
const iconFuncs = {
|
||||
new: () => Icons.newBadge('icon--sm'),
|
||||
burst: () => Icons.meter('icon--sm'),
|
||||
pattern: () => Icons.refresh('icon--sm'),
|
||||
flagged: () => Icons.flag('icon--sm'),
|
||||
gone: () => Icons.offline('icon--sm')
|
||||
};
|
||||
const iconHtml = iconFuncs[ann.type] ? iconFuncs[ann.type]() : Icons.sensor('icon--sm');
|
||||
return `
|
||||
<div class="signal-timeline-annotation" data-type="${ann.type}">
|
||||
<span class="signal-timeline-annotation-icon">${iconHtml}</span>
|
||||
<span>${ann.message}</span>
|
||||
<span style="margin-left: auto; opacity: 0.6;">${formatTimeAgo(ann.timestamp)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
function formatTime(timestamp) {
|
||||
return new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format short time for axis
|
||||
*/
|
||||
function formatTimeShort(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time ago
|
||||
*/
|
||||
function formatTimeAgo(timestamp) {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
function formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
function clear() {
|
||||
state.signals.clear();
|
||||
state.annotations = [];
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export data for reports
|
||||
*/
|
||||
function exportData() {
|
||||
const signals = Array.from(state.signals.values()).map(s => ({
|
||||
frequency: s.frequency,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
pattern: s.pattern,
|
||||
firstSeen: new Date(s.firstSeen).toISOString(),
|
||||
lastSeen: new Date(s.lastSeen).toISOString(),
|
||||
transmissionCount: s.transmissionCount,
|
||||
flagged: s.flagged
|
||||
}));
|
||||
|
||||
return {
|
||||
exportTime: new Date().toISOString(),
|
||||
timeWindow: state.timeWindow,
|
||||
signals: signals,
|
||||
annotations: state.annotations.map(a => ({
|
||||
...a,
|
||||
timestamp: new Date(a.timestamp).toISOString()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary stats
|
||||
*/
|
||||
function getStats() {
|
||||
const signals = Array.from(state.signals.values());
|
||||
return {
|
||||
total: signals.length,
|
||||
new: signals.filter(s => s.status === 'new').length,
|
||||
baseline: signals.filter(s => s.status === 'baseline').length,
|
||||
burst: signals.filter(s => s.status === 'burst').length,
|
||||
flagged: signals.filter(s => s.flagged).length,
|
||||
withPattern: signals.filter(s => s.pattern).length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the timeline
|
||||
*/
|
||||
function destroy() {
|
||||
stopUpdateTimer();
|
||||
if (state.tooltip) {
|
||||
state.tooltip.remove();
|
||||
state.tooltip = null;
|
||||
}
|
||||
const timeline = document.getElementById('signalTimeline');
|
||||
if (timeline) {
|
||||
timeline.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
// Initialization
|
||||
create: createTimeline,
|
||||
destroy: destroy,
|
||||
|
||||
// Data management
|
||||
addEvent: addEvent,
|
||||
flagSignal: flagSignal,
|
||||
markGone: markGone,
|
||||
clear: clear,
|
||||
|
||||
// Rendering
|
||||
render: render,
|
||||
|
||||
// Data access
|
||||
getSignals: () => Array.from(state.signals.values()),
|
||||
getAnnotations: () => state.annotations,
|
||||
getStats: getStats,
|
||||
exportData: exportData,
|
||||
|
||||
// Configuration
|
||||
setTimeWindow: (window) => {
|
||||
if (config.timeWindows[window]) {
|
||||
state.timeWindow = window;
|
||||
render();
|
||||
}
|
||||
},
|
||||
|
||||
// Filter controls
|
||||
setFilter: (filter, value) => {
|
||||
if (state.filters.hasOwnProperty(filter)) {
|
||||
state.filters[filter] = value;
|
||||
render();
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.SignalTimeline = SignalTimeline;
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Bluetooth Timeline Adapter
|
||||
* Normalizes Bluetooth device data for the Activity Timeline component
|
||||
* Used by: Bluetooth mode, TSCM (Bluetooth detections)
|
||||
*/
|
||||
|
||||
const BluetoothTimelineAdapter = (function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* RSSI to strength category mapping for Bluetooth
|
||||
* Bluetooth RSSI typically ranges from -30 (very close) to -100 (far)
|
||||
*/
|
||||
const RSSI_THRESHOLDS = {
|
||||
VERY_STRONG: -45, // 5 - device likely within 1m
|
||||
STRONG: -60, // 4 - device likely within 3m
|
||||
MODERATE: -75, // 3 - device likely within 10m
|
||||
WEAK: -90, // 2 - device at edge of range
|
||||
MINIMAL: -100 // 1 - barely detectable
|
||||
};
|
||||
|
||||
/**
|
||||
* Known device type patterns
|
||||
*/
|
||||
const DEVICE_PATTERNS = {
|
||||
// Apple devices
|
||||
AIRPODS: /airpods/i,
|
||||
IPHONE: /iphone/i,
|
||||
IPAD: /ipad/i,
|
||||
MACBOOK: /macbook|mac\s*pro|imac/i,
|
||||
APPLE_WATCH: /apple\s*watch/i,
|
||||
AIRTAG: /airtag/i,
|
||||
|
||||
// Trackers
|
||||
TILE: /tile/i,
|
||||
CHIPOLO: /chipolo/i,
|
||||
SAMSUNG_TAG: /smarttag|galaxy\s*tag/i,
|
||||
|
||||
// Audio
|
||||
HEADPHONES: /headphone|earphone|earbud|bose|sony|beats|jabra|sennheiser/i,
|
||||
SPEAKER: /speaker|soundbar|echo|homepod|sonos/i,
|
||||
|
||||
// Wearables
|
||||
FITBIT: /fitbit/i,
|
||||
GARMIN: /garmin/i,
|
||||
SMARTWATCH: /watch|band|mi\s*band|galaxy\s*fit/i,
|
||||
|
||||
// Input devices
|
||||
KEYBOARD: /keyboard/i,
|
||||
MOUSE: /mouse|trackpad|magic/i,
|
||||
CONTROLLER: /controller|gamepad|xbox|playstation|dualshock/i,
|
||||
|
||||
// Vehicles
|
||||
CAR: /car\s*kit|handsfree|obd|vehicle|toyota|honda|ford|bmw|mercedes/i
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert RSSI to strength category
|
||||
*/
|
||||
function rssiToStrength(rssi) {
|
||||
if (rssi === null || rssi === undefined) return 3;
|
||||
|
||||
const r = parseFloat(rssi);
|
||||
if (isNaN(r)) return 3;
|
||||
|
||||
if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5;
|
||||
if (r > RSSI_THRESHOLDS.STRONG) return 4;
|
||||
if (r > RSSI_THRESHOLDS.MODERATE) return 3;
|
||||
if (r > RSSI_THRESHOLDS.WEAK) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify device type from name
|
||||
*/
|
||||
function classifyDevice(name) {
|
||||
if (!name) return { type: 'unknown', category: 'device' };
|
||||
|
||||
for (const [pattern, regex] of Object.entries(DEVICE_PATTERNS)) {
|
||||
if (regex.test(name)) {
|
||||
return {
|
||||
type: pattern.toLowerCase(),
|
||||
category: getCategoryForType(pattern)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'unknown', category: 'device' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category for device type
|
||||
*/
|
||||
function getCategoryForType(type) {
|
||||
const categories = {
|
||||
AIRPODS: 'audio',
|
||||
IPHONE: 'phone',
|
||||
IPAD: 'tablet',
|
||||
MACBOOK: 'computer',
|
||||
APPLE_WATCH: 'wearable',
|
||||
AIRTAG: 'tracker',
|
||||
TILE: 'tracker',
|
||||
CHIPOLO: 'tracker',
|
||||
SAMSUNG_TAG: 'tracker',
|
||||
HEADPHONES: 'audio',
|
||||
SPEAKER: 'audio',
|
||||
FITBIT: 'wearable',
|
||||
GARMIN: 'wearable',
|
||||
SMARTWATCH: 'wearable',
|
||||
KEYBOARD: 'input',
|
||||
MOUSE: 'input',
|
||||
CONTROLLER: 'input',
|
||||
CAR: 'vehicle'
|
||||
};
|
||||
return categories[type] || 'device';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MAC address for display (truncated)
|
||||
*/
|
||||
function formatMac(mac, full = false) {
|
||||
if (!mac) return 'Unknown';
|
||||
if (full) return mac.toUpperCase();
|
||||
return mac.substring(0, 8).toUpperCase() + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if device is a tracker type
|
||||
*/
|
||||
function isTracker(device) {
|
||||
if (device.is_tracker) return true;
|
||||
|
||||
const name = device.name || '';
|
||||
return /airtag|tile|chipolo|smarttag|tracker/i.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Bluetooth device detection for the timeline
|
||||
*/
|
||||
function normalizeDevice(device) {
|
||||
const mac = device.mac || device.address || device.id;
|
||||
const name = device.name || device.device_name || formatMac(mac);
|
||||
const classification = classifyDevice(name);
|
||||
|
||||
const tags = [device.type || 'ble'];
|
||||
tags.push(classification.category);
|
||||
|
||||
if (isTracker(device)) tags.push('tracker');
|
||||
if (device.is_beacon) tags.push('beacon');
|
||||
if (device.is_connectable) tags.push('connectable');
|
||||
if (device.manufacturer) tags.push('identified');
|
||||
|
||||
return {
|
||||
id: mac,
|
||||
label: name,
|
||||
strength: rssiToStrength(device.rssi),
|
||||
duration: device.scan_duration || device.duration || 1000,
|
||||
type: classification.type,
|
||||
tags: tags,
|
||||
metadata: {
|
||||
mac: mac,
|
||||
rssi: device.rssi,
|
||||
device_type: device.type,
|
||||
manufacturer: device.manufacturer,
|
||||
services: device.services,
|
||||
is_tracker: isTracker(device),
|
||||
classification: classification
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize for TSCM context (includes threat assessment)
|
||||
*/
|
||||
function normalizeTscmDevice(device) {
|
||||
const normalized = normalizeDevice(device);
|
||||
|
||||
// Add TSCM-specific tags
|
||||
if (device.is_new) normalized.tags.push('new');
|
||||
if (device.threat_level) normalized.tags.push(`threat-${device.threat_level}`);
|
||||
if (device.baseline_known === false) normalized.tags.push('unknown');
|
||||
|
||||
normalized.metadata.threat_level = device.threat_level;
|
||||
normalized.metadata.first_seen = device.first_seen;
|
||||
normalized.metadata.appearance_count = device.appearance_count;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch normalize multiple devices
|
||||
*/
|
||||
function normalizeDevices(devices, context = 'scan') {
|
||||
const normalizer = context === 'tscm' ? normalizeTscmDevice : normalizeDevice;
|
||||
return devices.map(normalizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for Bluetooth mode
|
||||
*/
|
||||
function getBluetoothConfig() {
|
||||
return {
|
||||
title: 'Device Activity',
|
||||
mode: 'bluetooth',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
showLegend: true,
|
||||
defaultWindow: '15m',
|
||||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||||
filters: {
|
||||
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
|
||||
showOnlyNew: { enabled: true, label: 'New Only', default: false },
|
||||
showOnlyBurst: { enabled: false, label: 'Bursts', default: false }
|
||||
},
|
||||
customFilters: [
|
||||
{
|
||||
key: 'showOnlyTrackers',
|
||||
label: 'Trackers Only',
|
||||
default: false,
|
||||
predicate: (item) => item.tags.includes('tracker')
|
||||
},
|
||||
{
|
||||
key: 'hideWearables',
|
||||
label: 'Hide Wearables',
|
||||
default: false,
|
||||
predicate: (item) => !item.tags.includes('wearable')
|
||||
}
|
||||
],
|
||||
maxItems: 75,
|
||||
maxDisplayedLanes: 12,
|
||||
labelGenerator: (id) => formatMac(id)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create compact timeline configuration (for sidebar use)
|
||||
*/
|
||||
function getCompactConfig() {
|
||||
return {
|
||||
title: 'BT Devices',
|
||||
mode: 'bluetooth',
|
||||
visualMode: 'compact',
|
||||
collapsed: false,
|
||||
showAnnotations: false,
|
||||
showLegend: false,
|
||||
defaultWindow: '15m',
|
||||
availableWindows: ['5m', '15m', '30m'],
|
||||
filters: {
|
||||
hideBaseline: { enabled: false },
|
||||
showOnlyNew: { enabled: true, label: 'New', default: false },
|
||||
showOnlyBurst: { enabled: false }
|
||||
},
|
||||
customFilters: [],
|
||||
maxItems: 30,
|
||||
maxDisplayedLanes: 8
|
||||
};
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
// Normalization
|
||||
normalizeDevice: normalizeDevice,
|
||||
normalizeTscmDevice: normalizeTscmDevice,
|
||||
normalizeDevices: normalizeDevices,
|
||||
|
||||
// Utilities
|
||||
rssiToStrength: rssiToStrength,
|
||||
classifyDevice: classifyDevice,
|
||||
formatMac: formatMac,
|
||||
isTracker: isTracker,
|
||||
|
||||
// Configuration presets
|
||||
getBluetoothConfig: getBluetoothConfig,
|
||||
getCompactConfig: getCompactConfig,
|
||||
|
||||
// Constants
|
||||
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
|
||||
DEVICE_PATTERNS: DEVICE_PATTERNS
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = BluetoothTimelineAdapter;
|
||||
}
|
||||
|
||||
window.BluetoothTimelineAdapter = BluetoothTimelineAdapter;
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* RF Signal Timeline Adapter
|
||||
* Normalizes RF signal data for the Activity Timeline component
|
||||
* Used by: Listening Post, TSCM
|
||||
*/
|
||||
|
||||
const RFTimelineAdapter = (function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* RSSI to strength category mapping
|
||||
* Uses confidence-safe thresholds
|
||||
*/
|
||||
const RSSI_THRESHOLDS = {
|
||||
VERY_STRONG: -40, // 5 - indicates likely nearby source
|
||||
STRONG: -55, // 4 - probable close proximity
|
||||
MODERATE: -70, // 3 - likely in proximity
|
||||
WEAK: -85, // 2 - potentially distant or obstructed
|
||||
MINIMAL: -100 // 1 - may be ambient noise or distant source
|
||||
};
|
||||
|
||||
/**
|
||||
* Frequency band categorization
|
||||
*/
|
||||
const FREQUENCY_BANDS = [
|
||||
{ min: 2400, max: 2500, label: 'Wi-Fi 2.4GHz', type: 'wifi' },
|
||||
{ min: 5150, max: 5850, label: 'Wi-Fi 5GHz', type: 'wifi' },
|
||||
{ min: 5925, max: 7125, label: 'Wi-Fi 6E', type: 'wifi' },
|
||||
{ min: 2402, max: 2480, label: 'Bluetooth', type: 'bluetooth' },
|
||||
{ min: 433, max: 434, label: '433MHz ISM', type: 'ism' },
|
||||
{ min: 868, max: 869, label: '868MHz ISM', type: 'ism' },
|
||||
{ min: 902, max: 928, label: '915MHz ISM', type: 'ism' },
|
||||
{ min: 315, max: 316, label: '315MHz', type: 'keyfob' },
|
||||
{ min: 144, max: 148, label: 'VHF Ham', type: 'amateur' },
|
||||
{ min: 420, max: 450, label: 'UHF Ham', type: 'amateur' },
|
||||
{ min: 462.5625, max: 467.7125, label: 'FRS/GMRS', type: 'personal' },
|
||||
{ min: 151, max: 159, label: 'VHF Business', type: 'commercial' },
|
||||
{ min: 450, max: 470, label: 'UHF Business', type: 'commercial' },
|
||||
{ min: 88, max: 108, label: 'FM Broadcast', type: 'broadcast' },
|
||||
{ min: 118, max: 137, label: 'Airband', type: 'aviation' },
|
||||
{ min: 156, max: 162, label: 'Marine VHF', type: 'marine' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert RSSI (dBm) to strength category (1-5)
|
||||
*/
|
||||
function rssiToStrength(rssi) {
|
||||
if (rssi === null || rssi === undefined) return 3;
|
||||
|
||||
const r = parseFloat(rssi);
|
||||
if (isNaN(r)) return 3;
|
||||
|
||||
if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5;
|
||||
if (r > RSSI_THRESHOLDS.STRONG) return 4;
|
||||
if (r > RSSI_THRESHOLDS.MODERATE) return 3;
|
||||
if (r > RSSI_THRESHOLDS.WEAK) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize frequency into human-readable band name
|
||||
*/
|
||||
function categorizeFrequency(freqMHz) {
|
||||
const f = parseFloat(freqMHz);
|
||||
if (isNaN(f)) return { label: String(freqMHz), type: 'unknown' };
|
||||
|
||||
for (const band of FREQUENCY_BANDS) {
|
||||
if (f >= band.min && f <= band.max) {
|
||||
return { label: band.label, type: band.type };
|
||||
}
|
||||
}
|
||||
|
||||
// Generic labeling by range
|
||||
if (f < 30) return { label: `${f.toFixed(3)} MHz HF`, type: 'hf' };
|
||||
if (f < 300) return { label: `${f.toFixed(3)} MHz VHF`, type: 'vhf' };
|
||||
if (f < 3000) return { label: `${f.toFixed(3)} MHz UHF`, type: 'uhf' };
|
||||
return { label: `${f.toFixed(3)} MHz`, type: 'unknown' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a scanner signal detection for the timeline
|
||||
*/
|
||||
function normalizeSignal(signalData) {
|
||||
const freq = signalData.frequency || signalData.freq;
|
||||
const category = categorizeFrequency(freq);
|
||||
|
||||
return {
|
||||
id: String(freq),
|
||||
label: signalData.name || category.label,
|
||||
strength: rssiToStrength(signalData.rssi || signalData.signal_strength),
|
||||
duration: signalData.duration || 1000,
|
||||
type: category.type,
|
||||
tags: buildTags(signalData, category),
|
||||
metadata: {
|
||||
frequency: freq,
|
||||
rssi: signalData.rssi,
|
||||
modulation: signalData.modulation,
|
||||
bandwidth: signalData.bandwidth
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a TSCM RF detection
|
||||
*/
|
||||
function normalizeTscmSignal(detection) {
|
||||
const freq = detection.frequency;
|
||||
const category = categorizeFrequency(freq);
|
||||
|
||||
const tags = buildTags(detection, category);
|
||||
|
||||
// Add TSCM-specific tags
|
||||
if (detection.is_new) tags.push('new');
|
||||
if (detection.baseline_deviation) tags.push('deviation');
|
||||
if (detection.threat_level) tags.push(`threat-${detection.threat_level}`);
|
||||
|
||||
return {
|
||||
id: String(freq),
|
||||
label: detection.name || category.label,
|
||||
strength: rssiToStrength(detection.rssi),
|
||||
duration: detection.duration || 1000,
|
||||
type: category.type,
|
||||
tags: tags,
|
||||
metadata: {
|
||||
frequency: freq,
|
||||
rssi: detection.rssi,
|
||||
threat_level: detection.threat_level,
|
||||
source: detection.source
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tags array from signal data
|
||||
*/
|
||||
function buildTags(data, category) {
|
||||
const tags = [];
|
||||
|
||||
if (category.type) tags.push(category.type);
|
||||
|
||||
if (data.modulation) {
|
||||
tags.push(data.modulation.toLowerCase());
|
||||
}
|
||||
|
||||
if (data.is_burst) tags.push('burst');
|
||||
if (data.is_continuous) tags.push('continuous');
|
||||
if (data.is_periodic) tags.push('periodic');
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch normalize multiple signals
|
||||
*/
|
||||
function normalizeSignals(signals, type = 'scanner') {
|
||||
const normalizer = type === 'tscm' ? normalizeTscmSignal : normalizeSignal;
|
||||
return signals.map(normalizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for Listening Post mode
|
||||
*/
|
||||
function getListeningPostConfig() {
|
||||
return {
|
||||
title: 'Signal Activity',
|
||||
mode: 'listening-post',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
showLegend: true,
|
||||
defaultWindow: '15m',
|
||||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||||
filters: {
|
||||
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
|
||||
showOnlyNew: { enabled: true, label: 'New Only', default: false },
|
||||
showOnlyBurst: { enabled: true, label: 'Bursts', default: false }
|
||||
},
|
||||
customFilters: [
|
||||
{
|
||||
key: 'hideIsm',
|
||||
label: 'Hide ISM',
|
||||
default: false,
|
||||
predicate: (item) => !item.tags.includes('ism')
|
||||
}
|
||||
],
|
||||
maxItems: 50,
|
||||
maxDisplayedLanes: 12
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for TSCM mode
|
||||
*/
|
||||
function getTscmConfig() {
|
||||
return {
|
||||
title: 'Signal Activity Timeline',
|
||||
mode: 'tscm',
|
||||
visualMode: 'enriched',
|
||||
collapsed: true,
|
||||
showAnnotations: true,
|
||||
showLegend: true,
|
||||
defaultWindow: '30m',
|
||||
availableWindows: ['5m', '15m', '30m', '1h', '2h'],
|
||||
filters: {
|
||||
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
|
||||
showOnlyNew: { enabled: true, label: 'New Only', default: false },
|
||||
showOnlyBurst: { enabled: true, label: 'Bursts', default: false }
|
||||
},
|
||||
customFilters: [],
|
||||
maxItems: 100,
|
||||
maxDisplayedLanes: 15
|
||||
};
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
// Normalization
|
||||
normalizeSignal: normalizeSignal,
|
||||
normalizeTscmSignal: normalizeTscmSignal,
|
||||
normalizeSignals: normalizeSignals,
|
||||
|
||||
// Utilities
|
||||
rssiToStrength: rssiToStrength,
|
||||
categorizeFrequency: categorizeFrequency,
|
||||
|
||||
// Configuration presets
|
||||
getListeningPostConfig: getListeningPostConfig,
|
||||
getTscmConfig: getTscmConfig,
|
||||
|
||||
// Constants
|
||||
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
|
||||
FREQUENCY_BANDS: FREQUENCY_BANDS
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = RFTimelineAdapter;
|
||||
}
|
||||
|
||||
window.RFTimelineAdapter = RFTimelineAdapter;
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* WiFi Timeline Adapter
|
||||
* Normalizes WiFi network data for the Activity Timeline component
|
||||
* Used by: WiFi mode, TSCM (WiFi detections)
|
||||
*/
|
||||
|
||||
const WiFiTimelineAdapter = (function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* RSSI to strength category mapping for WiFi
|
||||
*/
|
||||
const RSSI_THRESHOLDS = {
|
||||
EXCELLENT: -50, // 5 - excellent signal
|
||||
GOOD: -60, // 4 - good signal
|
||||
FAIR: -70, // 3 - fair signal
|
||||
WEAK: -80, // 2 - weak signal
|
||||
POOR: -90 // 1 - very weak
|
||||
};
|
||||
|
||||
/**
|
||||
* WiFi channel to frequency band mapping
|
||||
*/
|
||||
const CHANNEL_BANDS = {
|
||||
// 2.4 GHz (channels 1-14)
|
||||
'2.4GHz': { min: 1, max: 14 },
|
||||
// 5 GHz (channels 32-177)
|
||||
'5GHz': { min: 32, max: 177 },
|
||||
// 6 GHz (channels 1-233, WiFi 6E)
|
||||
'6GHz': { min: 1, max: 233, is6e: true }
|
||||
};
|
||||
|
||||
/**
|
||||
* Security type classifications
|
||||
*/
|
||||
const SECURITY_TYPES = {
|
||||
OPEN: 'open',
|
||||
WEP: 'wep',
|
||||
WPA: 'wpa',
|
||||
WPA2: 'wpa2',
|
||||
WPA3: 'wpa3',
|
||||
ENTERPRISE: 'enterprise'
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert RSSI to strength category
|
||||
*/
|
||||
function rssiToStrength(rssi) {
|
||||
if (rssi === null || rssi === undefined) return 3;
|
||||
|
||||
const r = parseFloat(rssi);
|
||||
if (isNaN(r)) return 3;
|
||||
|
||||
if (r > RSSI_THRESHOLDS.EXCELLENT) return 5;
|
||||
if (r > RSSI_THRESHOLDS.GOOD) return 4;
|
||||
if (r > RSSI_THRESHOLDS.FAIR) return 3;
|
||||
if (r > RSSI_THRESHOLDS.WEAK) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine frequency band from channel
|
||||
*/
|
||||
function getBandFromChannel(channel, frequency) {
|
||||
if (frequency) {
|
||||
const f = parseFloat(frequency);
|
||||
if (f >= 5925) return '6GHz';
|
||||
if (f >= 5000) return '5GHz';
|
||||
if (f >= 2400) return '2.4GHz';
|
||||
}
|
||||
|
||||
const ch = parseInt(channel);
|
||||
if (isNaN(ch)) return 'unknown';
|
||||
|
||||
// This is simplified - in practice 6GHz also uses channels 1+
|
||||
// but typically reported with frequency
|
||||
if (ch <= 14) return '2.4GHz';
|
||||
if (ch >= 32 && ch <= 177) return '5GHz';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify security type
|
||||
*/
|
||||
function classifySecurity(network) {
|
||||
const security = (network.security || network.encryption || '').toLowerCase();
|
||||
const auth = (network.auth || '').toLowerCase();
|
||||
|
||||
if (!security || security === 'none' || security === 'open') {
|
||||
return SECURITY_TYPES.OPEN;
|
||||
}
|
||||
if (security.includes('wep')) return SECURITY_TYPES.WEP;
|
||||
if (security.includes('wpa3')) return SECURITY_TYPES.WPA3;
|
||||
if (security.includes('wpa2') || security.includes('rsn')) {
|
||||
if (auth.includes('eap') || auth.includes('802.1x') || auth.includes('enterprise')) {
|
||||
return SECURITY_TYPES.ENTERPRISE;
|
||||
}
|
||||
return SECURITY_TYPES.WPA2;
|
||||
}
|
||||
if (security.includes('wpa')) return SECURITY_TYPES.WPA;
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate SSID for display
|
||||
*/
|
||||
function formatSsid(ssid, maxLength = 20) {
|
||||
if (!ssid) return '[Hidden]';
|
||||
if (ssid.length <= maxLength) return ssid;
|
||||
return ssid.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify potentially interesting network characteristics
|
||||
*/
|
||||
function identifyCharacteristics(network) {
|
||||
const characteristics = [];
|
||||
const ssid = (network.ssid || '').toLowerCase();
|
||||
|
||||
// Hidden network
|
||||
if (!network.ssid || network.is_hidden) {
|
||||
characteristics.push('hidden');
|
||||
}
|
||||
|
||||
// Open network
|
||||
if (classifySecurity(network) === SECURITY_TYPES.OPEN) {
|
||||
characteristics.push('open');
|
||||
}
|
||||
|
||||
// Weak security
|
||||
if (classifySecurity(network) === SECURITY_TYPES.WEP) {
|
||||
characteristics.push('weak-security');
|
||||
}
|
||||
|
||||
// Potential hotspot
|
||||
if (/hotspot|mobile|tether|android|iphone/i.test(ssid)) {
|
||||
characteristics.push('hotspot');
|
||||
}
|
||||
|
||||
// Guest network
|
||||
if (/guest|visitor|public/i.test(ssid)) {
|
||||
characteristics.push('guest');
|
||||
}
|
||||
|
||||
// IoT device
|
||||
if (/ring|nest|ecobee|smartthings|wyze|arlo|hue|lifx/i.test(ssid)) {
|
||||
characteristics.push('iot');
|
||||
}
|
||||
|
||||
return characteristics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a WiFi network detection for the timeline
|
||||
*/
|
||||
function normalizeNetwork(network) {
|
||||
const ssid = network.ssid || network.essid || '';
|
||||
const bssid = network.bssid || network.mac || '';
|
||||
const band = getBandFromChannel(network.channel, network.frequency);
|
||||
const security = classifySecurity(network);
|
||||
const characteristics = identifyCharacteristics(network);
|
||||
|
||||
const tags = [band, security, ...characteristics];
|
||||
|
||||
return {
|
||||
id: bssid || ssid,
|
||||
label: formatSsid(ssid) || formatMac(bssid),
|
||||
strength: rssiToStrength(network.rssi || network.signal),
|
||||
duration: network.duration || 1000,
|
||||
type: 'wifi',
|
||||
tags: tags.filter(Boolean),
|
||||
metadata: {
|
||||
ssid: ssid,
|
||||
bssid: bssid,
|
||||
channel: network.channel,
|
||||
frequency: network.frequency,
|
||||
rssi: network.rssi || network.signal,
|
||||
security: security,
|
||||
band: band,
|
||||
characteristics: characteristics
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize for TSCM context
|
||||
*/
|
||||
function normalizeTscmNetwork(network) {
|
||||
const normalized = normalizeNetwork(network);
|
||||
|
||||
// Add TSCM-specific tags
|
||||
if (network.is_new) normalized.tags.push('new');
|
||||
if (network.threat_level) normalized.tags.push(`threat-${network.threat_level}`);
|
||||
if (network.is_rogue) normalized.tags.push('rogue');
|
||||
if (network.is_deauth_target) normalized.tags.push('targeted');
|
||||
|
||||
normalized.metadata.threat_level = network.threat_level;
|
||||
normalized.metadata.first_seen = network.first_seen;
|
||||
normalized.metadata.client_count = network.client_count;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MAC/BSSID for display
|
||||
*/
|
||||
function formatMac(mac) {
|
||||
if (!mac) return 'Unknown';
|
||||
return mac.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch normalize multiple networks
|
||||
*/
|
||||
function normalizeNetworks(networks, context = 'scan') {
|
||||
const normalizer = context === 'tscm' ? normalizeTscmNetwork : normalizeNetwork;
|
||||
return networks.map(normalizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for WiFi mode
|
||||
*/
|
||||
function getWiFiConfig() {
|
||||
return {
|
||||
title: 'Network Activity',
|
||||
mode: 'wifi',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
showLegend: true,
|
||||
defaultWindow: '15m',
|
||||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||||
filters: {
|
||||
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
|
||||
showOnlyNew: { enabled: true, label: 'New Only', default: false },
|
||||
showOnlyBurst: { enabled: false, label: 'Bursts', default: false }
|
||||
},
|
||||
customFilters: [
|
||||
{
|
||||
key: 'showOnlyOpen',
|
||||
label: 'Open Only',
|
||||
default: false,
|
||||
predicate: (item) => item.tags.includes('open')
|
||||
},
|
||||
{
|
||||
key: 'hideHidden',
|
||||
label: 'Hide Hidden',
|
||||
default: false,
|
||||
predicate: (item) => !item.tags.includes('hidden')
|
||||
},
|
||||
{
|
||||
key: 'show5GHz',
|
||||
label: '5GHz Only',
|
||||
default: false,
|
||||
predicate: (item) => item.tags.includes('5GHz')
|
||||
}
|
||||
],
|
||||
maxItems: 100,
|
||||
maxDisplayedLanes: 15,
|
||||
labelGenerator: (id) => formatSsid(id)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create compact configuration for sidebar
|
||||
*/
|
||||
function getCompactConfig() {
|
||||
return {
|
||||
title: 'Networks',
|
||||
mode: 'wifi',
|
||||
visualMode: 'compact',
|
||||
collapsed: false,
|
||||
showAnnotations: false,
|
||||
showLegend: false,
|
||||
defaultWindow: '15m',
|
||||
availableWindows: ['5m', '15m', '30m'],
|
||||
filters: {
|
||||
hideBaseline: { enabled: false },
|
||||
showOnlyNew: { enabled: true, label: 'New', default: false },
|
||||
showOnlyBurst: { enabled: false }
|
||||
},
|
||||
customFilters: [],
|
||||
maxItems: 30,
|
||||
maxDisplayedLanes: 8
|
||||
};
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
// Normalization
|
||||
normalizeNetwork: normalizeNetwork,
|
||||
normalizeTscmNetwork: normalizeTscmNetwork,
|
||||
normalizeNetworks: normalizeNetworks,
|
||||
|
||||
// Utilities
|
||||
rssiToStrength: rssiToStrength,
|
||||
getBandFromChannel: getBandFromChannel,
|
||||
classifySecurity: classifySecurity,
|
||||
formatSsid: formatSsid,
|
||||
identifyCharacteristics: identifyCharacteristics,
|
||||
|
||||
// Configuration presets
|
||||
getWiFiConfig: getWiFiConfig,
|
||||
getCompactConfig: getCompactConfig,
|
||||
|
||||
// Constants
|
||||
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
|
||||
SECURITY_TYPES: SECURITY_TYPES
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = WiFiTimelineAdapter;
|
||||
}
|
||||
|
||||
window.WiFiTimelineAdapter = WiFiTimelineAdapter;
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Timeline Heatmap Component
|
||||
*
|
||||
* Displays RSSI signal history as a heatmap grid.
|
||||
* Y-axis: devices, X-axis: time buckets, Cell color: RSSI strength
|
||||
*/
|
||||
|
||||
const TimelineHeatmap = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
cellWidth: 8,
|
||||
cellHeight: 20,
|
||||
labelWidth: 120,
|
||||
maxDevices: 20,
|
||||
refreshInterval: 5000,
|
||||
// RSSI color scale (green = strong, red = weak)
|
||||
colorScale: [
|
||||
{ rssi: -40, color: '#22c55e' }, // Strong - green
|
||||
{ rssi: -55, color: '#84cc16' }, // Good - lime
|
||||
{ rssi: -65, color: '#eab308' }, // Medium - yellow
|
||||
{ rssi: -75, color: '#f97316' }, // Weak - orange
|
||||
{ rssi: -90, color: '#ef4444' }, // Very weak - red
|
||||
],
|
||||
noDataColor: '#2a2a3e',
|
||||
};
|
||||
|
||||
// State
|
||||
let container = null;
|
||||
let contentEl = null;
|
||||
let controlsEl = null;
|
||||
let data = null;
|
||||
let isPaused = false;
|
||||
let refreshTimer = null;
|
||||
let selectedDeviceKey = null;
|
||||
let onDeviceSelect = null;
|
||||
|
||||
// Settings
|
||||
let settings = {
|
||||
windowMinutes: 10,
|
||||
bucketSeconds: 10,
|
||||
sortBy: 'recency',
|
||||
topN: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the heatmap component
|
||||
*/
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('[TimelineHeatmap] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.onDeviceSelect) {
|
||||
onDeviceSelect = options.onDeviceSelect;
|
||||
}
|
||||
|
||||
// Merge options into settings
|
||||
Object.assign(settings, options);
|
||||
|
||||
createStructure();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the heatmap DOM structure
|
||||
*/
|
||||
function createStructure() {
|
||||
container.innerHTML = `
|
||||
<div class="timeline-heatmap-controls">
|
||||
<div class="heatmap-control-group">
|
||||
<label>Window:</label>
|
||||
<select id="heatmapWindow" class="heatmap-select">
|
||||
<option value="10" ${settings.windowMinutes === 10 ? 'selected' : ''}>10 min</option>
|
||||
<option value="30" ${settings.windowMinutes === 30 ? 'selected' : ''}>30 min</option>
|
||||
<option value="60" ${settings.windowMinutes === 60 ? 'selected' : ''}>60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-control-group">
|
||||
<label>Bucket:</label>
|
||||
<select id="heatmapBucket" class="heatmap-select">
|
||||
<option value="10" ${settings.bucketSeconds === 10 ? 'selected' : ''}>10s</option>
|
||||
<option value="30" ${settings.bucketSeconds === 30 ? 'selected' : ''}>30s</option>
|
||||
<option value="60" ${settings.bucketSeconds === 60 ? 'selected' : ''}>60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-control-group">
|
||||
<label>Sort:</label>
|
||||
<select id="heatmapSort" class="heatmap-select">
|
||||
<option value="recency" ${settings.sortBy === 'recency' ? 'selected' : ''}>Recent</option>
|
||||
<option value="strength" ${settings.sortBy === 'strength' ? 'selected' : ''}>Strength</option>
|
||||
<option value="activity" ${settings.sortBy === 'activity' ? 'selected' : ''}>Activity</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="heatmapPauseBtn" class="heatmap-btn ${isPaused ? 'active' : ''}">
|
||||
${isPaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="timeline-heatmap-content">
|
||||
<div class="heatmap-loading">Loading signal history...</div>
|
||||
</div>
|
||||
<div class="heatmap-legend">
|
||||
<span class="legend-label">Signal:</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #22c55e;"></span>Strong</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #eab308;"></span>Medium</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #ef4444;"></span>Weak</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: ${CONFIG.noDataColor};"></span>No data</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
contentEl = container.querySelector('.timeline-heatmap-content');
|
||||
controlsEl = container.querySelector('.timeline-heatmap-controls');
|
||||
|
||||
// Attach event listeners
|
||||
attachEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to controls
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
const windowSelect = container.querySelector('#heatmapWindow');
|
||||
const bucketSelect = container.querySelector('#heatmapBucket');
|
||||
const sortSelect = container.querySelector('#heatmapSort');
|
||||
const pauseBtn = container.querySelector('#heatmapPauseBtn');
|
||||
|
||||
windowSelect?.addEventListener('change', (e) => {
|
||||
settings.windowMinutes = parseInt(e.target.value, 10);
|
||||
refresh();
|
||||
});
|
||||
|
||||
bucketSelect?.addEventListener('change', (e) => {
|
||||
settings.bucketSeconds = parseInt(e.target.value, 10);
|
||||
refresh();
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener('change', (e) => {
|
||||
settings.sortBy = e.target.value;
|
||||
refresh();
|
||||
});
|
||||
|
||||
pauseBtn?.addEventListener('click', () => {
|
||||
isPaused = !isPaused;
|
||||
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
|
||||
pauseBtn.classList.toggle('active', isPaused);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh timer
|
||||
*/
|
||||
function startAutoRefresh() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!isPaused) {
|
||||
refresh();
|
||||
}
|
||||
}, CONFIG.refreshInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and render heatmap data
|
||||
*/
|
||||
async function refresh() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
top_n: settings.topN,
|
||||
window_minutes: settings.windowMinutes,
|
||||
bucket_seconds: settings.bucketSeconds,
|
||||
sort_by: settings.sortBy,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/bluetooth/heatmap/data?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch heatmap data');
|
||||
|
||||
data = await response.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error('[TimelineHeatmap] Refresh error:', err);
|
||||
contentEl.innerHTML = '<div class="heatmap-error">Failed to load data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the heatmap grid
|
||||
*/
|
||||
function render() {
|
||||
if (!data || !data.devices || data.devices.length === 0) {
|
||||
contentEl.innerHTML = '<div class="heatmap-empty">No signal history available yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate time buckets
|
||||
const windowMs = settings.windowMinutes * 60 * 1000;
|
||||
const bucketMs = settings.bucketSeconds * 1000;
|
||||
const numBuckets = Math.ceil(windowMs / bucketMs);
|
||||
const now = new Date();
|
||||
|
||||
// Generate time labels
|
||||
const timeLabels = [];
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const time = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
|
||||
if (i % Math.ceil(numBuckets / 6) === 0) {
|
||||
timeLabels.push(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
} else {
|
||||
timeLabels.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Build heatmap HTML
|
||||
let html = '<div class="heatmap-grid">';
|
||||
|
||||
// Time axis header
|
||||
html += `<div class="heatmap-row heatmap-header">
|
||||
<div class="heatmap-label"></div>
|
||||
<div class="heatmap-cells">
|
||||
${timeLabels.map(label =>
|
||||
`<div class="heatmap-time-label" style="width: ${CONFIG.cellWidth}px;">${label}</div>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Device rows
|
||||
data.devices.forEach(device => {
|
||||
const isSelected = device.device_key === selectedDeviceKey;
|
||||
const rowClass = isSelected ? 'heatmap-row selected' : 'heatmap-row';
|
||||
|
||||
// Create lookup for timeseries data
|
||||
const tsLookup = new Map();
|
||||
device.timeseries.forEach(point => {
|
||||
const ts = new Date(point.timestamp).getTime();
|
||||
tsLookup.set(ts, point.rssi);
|
||||
});
|
||||
|
||||
// Generate cells for each time bucket
|
||||
const cells = [];
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const bucketTime = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
|
||||
const bucketKey = Math.floor(bucketTime.getTime() / bucketMs) * bucketMs;
|
||||
|
||||
// Find closest timestamp in data
|
||||
let rssi = null;
|
||||
const tolerance = bucketMs;
|
||||
tsLookup.forEach((val, ts) => {
|
||||
if (Math.abs(ts - bucketKey) < tolerance) {
|
||||
rssi = val;
|
||||
}
|
||||
});
|
||||
|
||||
const color = rssi !== null ? getRssiColor(rssi) : CONFIG.noDataColor;
|
||||
const title = rssi !== null ? `${rssi} dBm` : 'No data';
|
||||
|
||||
cells.push(`<div class="heatmap-cell" style="width: ${CONFIG.cellWidth}px; height: ${CONFIG.cellHeight}px; background: ${color};" title="${title}"></div>`);
|
||||
}
|
||||
|
||||
const displayName = device.name || formatAddress(device.address) || device.device_key.substring(0, 12);
|
||||
const rssiDisplay = device.rssi_ema != null ? `${Math.round(device.rssi_ema)} dBm` : '--';
|
||||
|
||||
html += `
|
||||
<div class="${rowClass}" data-device-key="${escapeAttr(device.device_key)}">
|
||||
<div class="heatmap-label" title="${escapeHtml(device.name || device.address || '')}">
|
||||
<span class="device-name">${escapeHtml(displayName)}</span>
|
||||
<span class="device-rssi">${rssiDisplay}</span>
|
||||
</div>
|
||||
<div class="heatmap-cells">${cells.join('')}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
contentEl.innerHTML = html;
|
||||
|
||||
// Attach row click handlers
|
||||
contentEl.querySelectorAll('.heatmap-row:not(.heatmap-header)').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const deviceKey = row.getAttribute('data-device-key');
|
||||
selectDevice(deviceKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for RSSI value
|
||||
*/
|
||||
function getRssiColor(rssi) {
|
||||
const scale = CONFIG.colorScale;
|
||||
|
||||
// Find the appropriate color from scale
|
||||
for (let i = 0; i < scale.length; i++) {
|
||||
if (rssi >= scale[i].rssi) {
|
||||
return scale[i].color;
|
||||
}
|
||||
}
|
||||
return scale[scale.length - 1].color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MAC address for display
|
||||
*/
|
||||
function formatAddress(address) {
|
||||
if (!address) return null;
|
||||
const parts = address.split(':');
|
||||
if (parts.length === 6) {
|
||||
return `${parts[0]}:${parts[1]}:..${parts[5]}`;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a device row
|
||||
*/
|
||||
function selectDevice(deviceKey) {
|
||||
selectedDeviceKey = deviceKey === selectedDeviceKey ? null : deviceKey;
|
||||
|
||||
// Update row highlighting
|
||||
contentEl.querySelectorAll('.heatmap-row').forEach(row => {
|
||||
const isSelected = row.getAttribute('data-device-key') === selectedDeviceKey;
|
||||
row.classList.toggle('selected', isSelected);
|
||||
});
|
||||
|
||||
// Callback
|
||||
if (onDeviceSelect && selectedDeviceKey) {
|
||||
const device = data?.devices?.find(d => d.device_key === selectedDeviceKey);
|
||||
onDeviceSelect(selectedDeviceKey, device);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update with new data directly (for SSE integration)
|
||||
*/
|
||||
function updateData(newData) {
|
||||
if (isPaused) return;
|
||||
data = newData;
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set paused state
|
||||
*/
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
const pauseBtn = container?.querySelector('#heatmapPauseBtn');
|
||||
if (pauseBtn) {
|
||||
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
|
||||
pauseBtn.classList.toggle('active', isPaused);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the component
|
||||
*/
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe rendering
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
refresh,
|
||||
updateData,
|
||||
setPaused,
|
||||
destroy,
|
||||
selectDevice,
|
||||
getSelectedDevice: () => selectedDeviceKey,
|
||||
isPaused: () => isPaused,
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = TimelineHeatmap;
|
||||
}
|
||||
|
||||
window.TimelineHeatmap = TimelineHeatmap;
|
||||
@@ -78,37 +78,6 @@ function updateHeaderClock() {
|
||||
document.getElementById('headerUtcTime').textContent = utc;
|
||||
}
|
||||
|
||||
// ============== HEADER STATS SYNC ==============
|
||||
|
||||
function syncHeaderStats() {
|
||||
// Pager stats
|
||||
document.getElementById('headerMsgCount').textContent = msgCount;
|
||||
document.getElementById('headerPocsagCount').textContent = pocsagCount;
|
||||
document.getElementById('headerFlexCount').textContent = flexCount;
|
||||
|
||||
// Sensor stats
|
||||
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||||
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||||
|
||||
// WiFi stats
|
||||
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
|
||||
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
|
||||
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||||
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
|
||||
|
||||
// Bluetooth stats
|
||||
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
|
||||
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
|
||||
|
||||
// Aircraft stats
|
||||
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
|
||||
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
|
||||
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
|
||||
|
||||
// Satellite stats
|
||||
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
|
||||
}
|
||||
|
||||
// ============== MODE SWITCHING ==============
|
||||
|
||||
function switchMode(mode) {
|
||||
@@ -126,7 +95,7 @@ function switchMode(mode) {
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening'
|
||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -138,11 +107,16 @@ function switchMode(mode) {
|
||||
// Toggle mode content visibility
|
||||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
@@ -150,19 +124,10 @@ function switchMode(mode) {
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
|
||||
// Update header stats groups
|
||||
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||||
@@ -175,10 +140,21 @@ function switchMode(mode) {
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST'
|
||||
'listening': 'LISTENING POST',
|
||||
'tscm': 'TSCM',
|
||||
'aprs': 'APRS',
|
||||
'meshtastic': 'MESHTASTIC'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
|
||||
// Update mobile nav buttons
|
||||
updateMobileNavButtons(mode);
|
||||
|
||||
// Close mobile drawer when mode is switched (on mobile)
|
||||
if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') {
|
||||
window.closeMobileDrawer();
|
||||
}
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
@@ -197,7 +173,8 @@ function switchMode(mode) {
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post'
|
||||
'listening': 'Listening Post',
|
||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
@@ -227,10 +204,10 @@ function switchMode(mode) {
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
@@ -251,6 +228,8 @@ function switchMode(mode) {
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
} else if (mode === 'meshtastic') {
|
||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,94 +389,68 @@ function showError(text) {
|
||||
output.insertBefore(errorEl, output.firstChild);
|
||||
}
|
||||
|
||||
// ============== OBSERVER LOCATION ==============
|
||||
|
||||
function saveObserverLocation() {
|
||||
const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
|
||||
const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Sync both input sets
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function useGeolocation() {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lon = position.coords.longitude;
|
||||
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Update all input fields
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
|
||||
showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
|
||||
},
|
||||
(error) => {
|
||||
showError('Geolocation failed: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
showError('Geolocation not supported by browser');
|
||||
}
|
||||
}
|
||||
|
||||
// ============== EXPORT FUNCTIONS ==============
|
||||
|
||||
function exportCSV() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||||
const csv = [headers.join(',')];
|
||||
allMessages.forEach(msg => {
|
||||
const row = [
|
||||
msg.timestamp || '',
|
||||
msg.protocol || '',
|
||||
msg.address || '',
|
||||
msg.function || '',
|
||||
msg.msg_type || '',
|
||||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||||
];
|
||||
csv.push(row.join(','));
|
||||
});
|
||||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
// ============== MOBILE NAVIGATION ==============
|
||||
|
||||
function initMobileNav() {
|
||||
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||
const sidebar = document.getElementById('mainSidebar');
|
||||
const overlay = document.getElementById('drawerOverlay');
|
||||
|
||||
if (!hamburgerBtn || !sidebar || !overlay) return;
|
||||
|
||||
function openDrawer() {
|
||||
sidebar.classList.add('open');
|
||||
overlay.classList.add('visible');
|
||||
hamburgerBtn.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('visible');
|
||||
hamburgerBtn.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
if (sidebar.classList.contains('open')) {
|
||||
closeDrawer();
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
hamburgerBtn.addEventListener('click', toggleDrawer);
|
||||
overlay.addEventListener('click', closeDrawer);
|
||||
|
||||
// Close drawer when resizing to desktop
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for external use
|
||||
window.toggleMobileDrawer = toggleDrawer;
|
||||
window.closeMobileDrawer = closeDrawer;
|
||||
}
|
||||
|
||||
function setViewportHeight() {
|
||||
// Fix for iOS Safari address bar height
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
}
|
||||
|
||||
function updateMobileNavButtons(mode) {
|
||||
// Update mobile nav bar buttons
|
||||
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
|
||||
const btnMode = btn.getAttribute('data-mode');
|
||||
btn.classList.toggle('active', btnMode === mode);
|
||||
});
|
||||
}
|
||||
|
||||
function initApp() {
|
||||
// Check disclaimer
|
||||
checkDisclaimer();
|
||||
@@ -509,9 +462,6 @@ function initApp() {
|
||||
updateHeaderClock();
|
||||
setInterval(updateHeaderClock, 1000);
|
||||
|
||||
// Start stats sync
|
||||
setInterval(syncHeaderStats, 500);
|
||||
|
||||
// Load bias-T setting
|
||||
loadBiasTSetting();
|
||||
|
||||
@@ -541,6 +491,13 @@ function initApp() {
|
||||
section.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize mobile navigation
|
||||
initMobileNav();
|
||||
|
||||
// Set viewport height for mobile browsers
|
||||
setViewportHeight();
|
||||
window.addEventListener('resize', setViewportHeight);
|
||||
}
|
||||
|
||||
// Run initialization when DOM is ready
|
||||
|
||||
@@ -211,7 +211,7 @@ function isMuted() {
|
||||
function updateMuteButton() {
|
||||
const btn = document.getElementById('muteBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||||
btn.innerHTML = audioMuted ? Icons.volumeOff('icon--sm') + ' UNMUTE' : Icons.volumeOn('icon--sm') + ' MUTE';
|
||||
btn.classList.toggle('muted', audioMuted);
|
||||
}
|
||||
}
|
||||
@@ -226,7 +226,7 @@ function requestNotificationPermission() {
|
||||
Notification.requestPermission().then(permission => {
|
||||
notificationsEnabled = permission === 'granted';
|
||||
if (notificationsEnabled && typeof showInfo === 'function') {
|
||||
showInfo('🔔 Desktop notifications enabled');
|
||||
showInfo('Desktop notifications enabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Handles the visual transition and submission lock for the authorization terminal.
|
||||
* @param {Event} event - The click event from the submission button.
|
||||
*/
|
||||
function login(event) {
|
||||
const btn = event.currentTarget;
|
||||
const form = btn.closest('form');
|
||||
|
||||
// Validate form requirements before triggering visual effects
|
||||
if (!form.checkValidity()) {
|
||||
return; // Allow the browser to handle native "required" field alerts
|
||||
}
|
||||
|
||||
// 1. Visual Feedback: Transition to "Processing" state
|
||||
btn.style.color = "#ff4d4d";
|
||||
btn.style.borderColor = "#ff4d4d";
|
||||
btn.style.textShadow = "0 0 10px #ff4d4d";
|
||||
btn.style.transform = "scale(0.95)";
|
||||
|
||||
// Update button text to reflect terminal status
|
||||
const btnText = btn.querySelector('.btn-text');
|
||||
if (btnText) {
|
||||
btnText.innerText = "AUTHORIZING...";
|
||||
}
|
||||
|
||||
// 2. Security Lock: Prevent redundant requests (Double-click spam)
|
||||
// A 10ms delay ensures the browser successfully dispatches the POST request
|
||||
// before the UI element becomes non-interactive.
|
||||
setTimeout(() => {
|
||||
btn.style.pointerEvents = "none";
|
||||
btn.style.opacity = "0.7";
|
||||
btn.style.cursor = "not-allowed";
|
||||
}, 10);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Settings Manager - Handles offline mode and application settings
|
||||
*/
|
||||
|
||||
const Settings = {
|
||||
// Default settings
|
||||
defaults: {
|
||||
'offline.enabled': false,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'openstreetmap',
|
||||
'offline.tile_server_url': ''
|
||||
},
|
||||
|
||||
// Tile provider configurations
|
||||
tileProviders: {
|
||||
openstreetmap: {
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
subdomains: 'abc'
|
||||
},
|
||||
cartodb_dark: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd'
|
||||
},
|
||||
cartodb_light: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd'
|
||||
},
|
||||
esri_world: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||
subdomains: null
|
||||
}
|
||||
},
|
||||
|
||||
// Current settings cache
|
||||
_cache: {},
|
||||
|
||||
/**
|
||||
* Initialize settings - load from server/localStorage
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
const response = await fetch('/offline/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this._cache = { ...this.defaults, ...data.settings };
|
||||
} else {
|
||||
// Fall back to localStorage
|
||||
this._loadFromLocalStorage();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load settings from server, using localStorage:', e);
|
||||
this._loadFromLocalStorage();
|
||||
}
|
||||
|
||||
this._updateUI();
|
||||
return this._cache;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load settings from localStorage
|
||||
*/
|
||||
_loadFromLocalStorage() {
|
||||
const stored = localStorage.getItem('intercept_settings');
|
||||
if (stored) {
|
||||
try {
|
||||
this._cache = { ...this.defaults, ...JSON.parse(stored) };
|
||||
} catch (e) {
|
||||
this._cache = { ...this.defaults };
|
||||
}
|
||||
} else {
|
||||
this._cache = { ...this.defaults };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save a setting to server and localStorage
|
||||
*/
|
||||
async _save(key, value) {
|
||||
this._cache[key] = value;
|
||||
|
||||
// Save to localStorage as backup
|
||||
localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
|
||||
|
||||
// Save to server
|
||||
try {
|
||||
await fetch('/offline/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to save setting to server:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a setting value
|
||||
*/
|
||||
get(key) {
|
||||
return this._cache[key] ?? this.defaults[key];
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle offline mode master switch
|
||||
*/
|
||||
async toggleOfflineMode(enabled) {
|
||||
await this._save('offline.enabled', enabled);
|
||||
|
||||
if (enabled) {
|
||||
// When enabling offline mode, also switch assets and fonts to local
|
||||
await this._save('offline.assets_source', 'local');
|
||||
await this._save('offline.fonts_source', 'local');
|
||||
}
|
||||
|
||||
this._updateUI();
|
||||
this._showReloadPrompt();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set asset source (cdn or local)
|
||||
*/
|
||||
async setAssetSource(source) {
|
||||
await this._save('offline.assets_source', source);
|
||||
this._showReloadPrompt();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set fonts source (cdn or local)
|
||||
*/
|
||||
async setFontsSource(source) {
|
||||
await this._save('offline.fonts_source', source);
|
||||
this._showReloadPrompt();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set tile provider
|
||||
*/
|
||||
async setTileProvider(provider) {
|
||||
await this._save('offline.tile_provider', provider);
|
||||
|
||||
// Show/hide custom URL input
|
||||
const customRow = document.getElementById('customTileUrlRow');
|
||||
if (customRow) {
|
||||
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// If not custom and we have a map, update tiles immediately
|
||||
if (provider !== 'custom') {
|
||||
this._updateMapTiles();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set custom tile server URL
|
||||
*/
|
||||
async setCustomTileUrl(url) {
|
||||
await this._save('offline.tile_server_url', url);
|
||||
this._updateMapTiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current tile configuration
|
||||
*/
|
||||
getTileConfig() {
|
||||
const provider = this.get('offline.tile_provider');
|
||||
|
||||
if (provider === 'custom') {
|
||||
const customUrl = this.get('offline.tile_server_url');
|
||||
return {
|
||||
url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: 'Custom Tile Server',
|
||||
subdomains: 'abc'
|
||||
};
|
||||
}
|
||||
|
||||
return this.tileProviders[provider] || this.tileProviders.openstreetmap;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if local assets are available
|
||||
*/
|
||||
async checkAssets() {
|
||||
const assets = {
|
||||
leaflet: [
|
||||
'/static/vendor/leaflet/leaflet.js',
|
||||
'/static/vendor/leaflet/leaflet.css'
|
||||
],
|
||||
chartjs: [
|
||||
'/static/vendor/chartjs/chart.umd.min.js'
|
||||
],
|
||||
inter: [
|
||||
'/static/vendor/fonts/Inter-Regular.woff2'
|
||||
],
|
||||
jetbrains: [
|
||||
'/static/vendor/fonts/JetBrainsMono-Regular.woff2'
|
||||
]
|
||||
};
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const [name, urls] of Object.entries(assets)) {
|
||||
const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`);
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Checking...';
|
||||
statusEl.className = 'asset-badge checking';
|
||||
}
|
||||
|
||||
let available = true;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
if (!response.ok) {
|
||||
available = false;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
available = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results[name] = available;
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = available ? 'Available' : 'Missing';
|
||||
statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update UI elements to reflect current settings
|
||||
*/
|
||||
_updateUI() {
|
||||
// Offline mode toggle
|
||||
const offlineEnabled = document.getElementById('offlineEnabled');
|
||||
if (offlineEnabled) {
|
||||
offlineEnabled.checked = this.get('offline.enabled');
|
||||
}
|
||||
|
||||
// Assets source
|
||||
const assetsSource = document.getElementById('assetsSource');
|
||||
if (assetsSource) {
|
||||
assetsSource.value = this.get('offline.assets_source');
|
||||
}
|
||||
|
||||
// Fonts source
|
||||
const fontsSource = document.getElementById('fontsSource');
|
||||
if (fontsSource) {
|
||||
fontsSource.value = this.get('offline.fonts_source');
|
||||
}
|
||||
|
||||
// Tile provider
|
||||
const tileProvider = document.getElementById('tileProvider');
|
||||
if (tileProvider) {
|
||||
tileProvider.value = this.get('offline.tile_provider');
|
||||
}
|
||||
|
||||
// Custom tile URL
|
||||
const customTileUrl = document.getElementById('customTileUrl');
|
||||
if (customTileUrl) {
|
||||
customTileUrl.value = this.get('offline.tile_server_url') || '';
|
||||
}
|
||||
|
||||
// Show/hide custom URL row
|
||||
const customRow = document.getElementById('customTileUrlRow');
|
||||
if (customRow) {
|
||||
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update map tiles if a map exists
|
||||
*/
|
||||
_updateMapTiles() {
|
||||
// Look for common map variable names
|
||||
const maps = [
|
||||
window.map,
|
||||
window.leafletMap,
|
||||
window.aprsMap,
|
||||
window.adsbMap
|
||||
].filter(m => m && typeof m.eachLayer === 'function');
|
||||
|
||||
if (maps.length === 0) return;
|
||||
|
||||
const config = this.getTileConfig();
|
||||
|
||||
maps.forEach(map => {
|
||||
// Remove existing tile layers
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.TileLayer) {
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new tile layer
|
||||
const options = {
|
||||
attribution: config.attribution
|
||||
};
|
||||
if (config.subdomains) {
|
||||
options.subdomains = config.subdomains;
|
||||
}
|
||||
|
||||
L.tileLayer(config.url, options).addTo(map);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Show reload prompt
|
||||
*/
|
||||
_showReloadPrompt() {
|
||||
// Create or update reload prompt
|
||||
let prompt = document.getElementById('settingsReloadPrompt');
|
||||
if (!prompt) {
|
||||
prompt = document.createElement('div');
|
||||
prompt.id = 'settingsReloadPrompt';
|
||||
prompt.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--bg-dark, #0a0a0f);
|
||||
border: 1px solid var(--accent-cyan, #00d4ff);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 10001;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
prompt.innerHTML = `
|
||||
<span style="color: var(--text-primary, #e0e0e0); font-size: 13px;">
|
||||
Reload to apply changes
|
||||
</span>
|
||||
<button onclick="location.reload()" style="
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
border: none;
|
||||
color: #000;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
">Reload</button>
|
||||
<button onclick="this.parentElement.remove()" style="
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
">×</button>
|
||||
`;
|
||||
document.body.appendChild(prompt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Settings modal functions
|
||||
function showSettings() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
Settings.init().then(() => {
|
||||
Settings.checkAssets();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hideSettings() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function switchSettingsTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// Update sections
|
||||
document.querySelectorAll('.settings-section').forEach(section => {
|
||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize settings on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Settings.init();
|
||||
});
|
||||
@@ -271,3 +271,176 @@ function clamp(num, min, max) {
|
||||
function mapRange(value, inMin, inMax, outMin, outMax) {
|
||||
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
||||
}
|
||||
|
||||
// ============== ICON SYSTEM ==============
|
||||
// Minimal SVG icons. Each returns HTML string.
|
||||
// Designed for screenshot legibility - standard symbols only.
|
||||
|
||||
const Icons = {
|
||||
// ===== Signal Type Icons =====
|
||||
wifi: function(className) {
|
||||
return `<span class="icon icon-wifi ${className || ''}" aria-label="WiFi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>`;
|
||||
},
|
||||
bluetooth: function(className) {
|
||||
return `<span class="icon icon-bluetooth ${className || ''}" aria-label="Bluetooth"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>`;
|
||||
},
|
||||
cellular: function(className) {
|
||||
return `<span class="icon icon-cellular ${className || ''}" aria-label="Cellular"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="2" y="16" width="4" height="6" rx="1"/><rect x="8" y="12" width="4" height="10" rx="1"/><rect x="14" y="8" width="4" height="14" rx="1"/><rect x="20" y="4" width="4" height="18" rx="1" opacity="0.3"/></svg></span>`;
|
||||
},
|
||||
radio: function(className) {
|
||||
return `<span class="icon icon-radio ${className || ''}" aria-label="Radio"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>`;
|
||||
},
|
||||
|
||||
// ===== Mode Icons =====
|
||||
pager: function(className) {
|
||||
return `<span class="icon icon-pager ${className || ''}" aria-label="Pager"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span>`;
|
||||
},
|
||||
sensor: function(className) {
|
||||
return `<span class="icon icon-sensor ${className || ''}" aria-label="Sensor"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>`;
|
||||
},
|
||||
aircraft: function(className) {
|
||||
return `<span class="icon icon-aircraft ${className || ''}" aria-label="Aircraft"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span>`;
|
||||
},
|
||||
satellite: function(className) {
|
||||
return `<span class="icon icon-satellite ${className || ''}" aria-label="Satellite"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>`;
|
||||
},
|
||||
location: function(className) {
|
||||
return `<span class="icon icon-location ${className || ''}" aria-label="Location"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>`;
|
||||
},
|
||||
search: function(className) {
|
||||
return `<span class="icon icon-search ${className || ''}" aria-label="Search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>`;
|
||||
},
|
||||
meter: function(className) {
|
||||
return `<span class="icon icon-meter ${className || ''}" aria-label="Meter"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span>`;
|
||||
},
|
||||
scanner: function(className) {
|
||||
return `<span class="icon icon-scanner ${className || ''}" aria-label="Scanner"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span>`;
|
||||
},
|
||||
|
||||
// ===== Status Icons =====
|
||||
warning: function(className) {
|
||||
return `<span class="icon icon-warning ${className || ''}" aria-label="Warning"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>`;
|
||||
},
|
||||
check: function(className) {
|
||||
return `<span class="icon icon-check ${className || ''}" aria-label="Check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`;
|
||||
},
|
||||
x: function(className) {
|
||||
return `<span class="icon icon-x ${className || ''}" aria-label="X"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>`;
|
||||
},
|
||||
recording: function(className) {
|
||||
return `<span class="icon icon-recording ${className || ''}" aria-label="Recording"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="8"/></svg></span>`;
|
||||
},
|
||||
anomaly: function(className) {
|
||||
return `<span class="icon icon-anomaly ${className || ''}" aria-label="Anomaly"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></span>`;
|
||||
},
|
||||
flag: function(className) {
|
||||
return `<span class="icon icon-flag ${className || ''}" aria-label="Flag"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg></span>`;
|
||||
},
|
||||
newBadge: function(className) {
|
||||
return `<span class="icon icon-new ${className || ''}" aria-label="New"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>`;
|
||||
},
|
||||
offline: function(className) {
|
||||
return `<span class="icon icon-offline ${className || ''}" aria-label="Offline"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>`;
|
||||
},
|
||||
|
||||
// ===== Device Type Icons =====
|
||||
user: function(className) {
|
||||
return `<span class="icon icon-user ${className || ''}" aria-label="User"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>`;
|
||||
},
|
||||
drone: function(className) {
|
||||
return `<span class="icon icon-drone ${className || ''}" aria-label="Drone"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M3 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M9 9l-4 -1"/><path d="M15 9l4 -1"/><path d="M9 15l-4 1"/><path d="M15 15l4 1"/></svg></span>`;
|
||||
},
|
||||
military: function(className) {
|
||||
return `<span class="icon icon-military ${className || ''}" aria-label="Military"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>`;
|
||||
},
|
||||
handshake: function(className) {
|
||||
return `<span class="icon icon-handshake ${className || ''}" aria-label="Handshake"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/></svg></span>`;
|
||||
},
|
||||
|
||||
// ===== Action Icons =====
|
||||
refresh: function(className) {
|
||||
return `<span class="icon icon-refresh ${className || ''}" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span>`;
|
||||
},
|
||||
download: function(className) {
|
||||
return `<span class="icon icon-download ${className || ''}" aria-label="Download"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>`;
|
||||
},
|
||||
export: function(className) {
|
||||
return `<span class="icon icon-export ${className || ''}" aria-label="Export"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></span>`;
|
||||
},
|
||||
copy: function(className) {
|
||||
return `<span class="icon icon-copy ${className || ''}" aria-label="Copy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>`;
|
||||
},
|
||||
link: function(className) {
|
||||
return `<span class="icon icon-link ${className || ''}" aria-label="Link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>`;
|
||||
},
|
||||
chart: function(className) {
|
||||
return `<span class="icon icon-chart ${className || ''}" aria-label="Chart"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span>`;
|
||||
},
|
||||
star: function(className) {
|
||||
return `<span class="icon icon-star ${className || ''}" aria-label="Star"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>`;
|
||||
},
|
||||
target: function(className) {
|
||||
return `<span class="icon icon-target ${className || ''}" aria-label="Target"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg></span>`;
|
||||
},
|
||||
settings: function(className) {
|
||||
return `<span class="icon icon-settings ${className || ''}" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>`;
|
||||
},
|
||||
|
||||
// ===== Playback Controls =====
|
||||
play: function(className) {
|
||||
return `<span class="icon icon-play ${className || ''}" aria-label="Play"><svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></span>`;
|
||||
},
|
||||
pause: function(className) {
|
||||
return `<span class="icon icon-pause ${className || ''}" aria-label="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg></span>`;
|
||||
},
|
||||
stop: function(className) {
|
||||
return `<span class="icon icon-stop ${className || ''}" aria-label="Stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg></span>`;
|
||||
},
|
||||
headphones: function(className) {
|
||||
return `<span class="icon icon-headphones ${className || ''}" aria-label="Headphones"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg></span>`;
|
||||
},
|
||||
volumeOn: function(className) {
|
||||
return `<span class="icon icon-volume-on ${className || ''}" aria-label="Volume On"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>`;
|
||||
},
|
||||
volumeOff: function(className) {
|
||||
return `<span class="icon icon-volume-off ${className || ''}" aria-label="Volume Off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg></span>`;
|
||||
},
|
||||
|
||||
// ===== UI Icons =====
|
||||
sun: function(className) {
|
||||
return `<span class="icon icon-sun ${className || ''}" aria-label="Light mode"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>`;
|
||||
},
|
||||
moon: function(className) {
|
||||
return `<span class="icon icon-moon ${className || ''}" aria-label="Dark mode"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>`;
|
||||
},
|
||||
arrowDown: function(className) {
|
||||
return `<span class="icon icon-arrow-down ${className || ''}" aria-label="Arrow down"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></span>`;
|
||||
},
|
||||
chevronDown: function(className) {
|
||||
return `<span class="icon icon-chevron-down ${className || ''}" aria-label="Expand"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>`;
|
||||
},
|
||||
chevronRight: function(className) {
|
||||
return `<span class="icon icon-chevron-right ${className || ''}" aria-label="Right"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>`;
|
||||
},
|
||||
chevronLeft: function(className) {
|
||||
return `<span class="icon icon-chevron-left ${className || ''}" aria-label="Left"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></span>`;
|
||||
},
|
||||
mail: function(className) {
|
||||
return `<span class="icon icon-mail ${className || ''}" aria-label="Mail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span>`;
|
||||
},
|
||||
loader: function(className) {
|
||||
return `<span class="icon icon-loader ${className || ''}" aria-label="Loading"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg></span>`;
|
||||
},
|
||||
bell: function(className) {
|
||||
return `<span class="icon icon-bell ${className || ''}" aria-label="Notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>`;
|
||||
},
|
||||
|
||||
// ===== Helper function =====
|
||||
forSignalType: function(type, className) {
|
||||
const t = (type || '').toLowerCase();
|
||||
if (t.includes('wifi') || t.includes('802.11')) return this.wifi(className);
|
||||
if (t.includes('bluetooth') || t.includes('bt') || t.includes('ble')) return this.bluetooth(className);
|
||||
if (t.includes('cellular') || t.includes('lte') || t.includes('gsm') || t.includes('5g')) return this.cellular(className);
|
||||
return this.radio(className);
|
||||
}
|
||||
};
|
||||
|
||||