mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 22:21:55 -07:00
Merge main into misc-fixes and address PR #202 review
Sync with upstream main and fix required items from review: - updateTimelineLabels() now uses InterceptTime API (getTimezone/getIANA) instead of the stale selectedTimezone/TZ_MAP globals that were removed during the earlier InterceptTime refactor — fixes ReferenceError on TZ change and pass refresh. - Remove profiles: [basic] from the intercept service in docker-compose.yml so bare `docker compose up -d` still starts the main service. Profile-gated services (intercept-history, adsb_db) stay as-is.
This commit is contained in:
@@ -42,7 +42,6 @@ tasks/
|
||||
instance/
|
||||
|
||||
# data/ is a Python package — only exclude non-code files
|
||||
data/*.json
|
||||
data/*.csv
|
||||
data/*.db
|
||||
|
||||
|
||||
@@ -67,3 +67,5 @@ data/subghz/captures/
|
||||
|
||||
# Local utility scripts
|
||||
reset-sdr.*
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
@@ -0,0 +1,178 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.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, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Docker (Primary)
|
||||
```bash
|
||||
# Build and run (basic profile)
|
||||
docker compose --profile basic up -d
|
||||
|
||||
# Build and run with ADS-B history (Postgres)
|
||||
docker compose --profile history up -d
|
||||
|
||||
# Rebuild after code changes
|
||||
docker compose --profile basic up -d --build
|
||||
|
||||
# Multi-arch build (amd64 + arm64 for RPi)
|
||||
./build-multiarch.sh
|
||||
```
|
||||
|
||||
### Local Setup (Alternative)
|
||||
```bash
|
||||
# First-time setup (interactive wizard with install profiles)
|
||||
./setup.sh
|
||||
|
||||
# Or headless full install
|
||||
./setup.sh --non-interactive
|
||||
|
||||
# Or install specific profiles
|
||||
./setup.sh --profile=core,weather
|
||||
|
||||
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
|
||||
sudo ./start.sh
|
||||
|
||||
# Or for quick local dev (Flask dev server)
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Other setup utilities
|
||||
./setup.sh --health-check # Verify installation
|
||||
./setup.sh --postgres-setup # Set up ADS-B history database
|
||||
./setup.sh --menu # Force interactive menu
|
||||
```
|
||||
|
||||
### 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
|
||||
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
|
||||
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
|
||||
- `intercept.py` - Direct Flask dev server entry point (quick local development)
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
|
||||
|
||||
### Route Blueprints (routes/)
|
||||
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
|
||||
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||
- `aprs.py` - Amateur packet radio via direwolf
|
||||
- `rtlamr.py` - Utility meter reading
|
||||
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||
|
||||
### Core Utilities (utils/)
|
||||
|
||||
**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
|
||||
|
||||
**Weather Satellite** (`utils/weather_sat.py`):
|
||||
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||
- Subprocess management with stdout parsing, image watcher via rglob
|
||||
- Pass prediction using skyfield TLE data
|
||||
|
||||
**SSTV Decoder** (`utils/sstv.py`):
|
||||
- ISS SSTV reception via slowrx with Doppler tracking
|
||||
- Singleton pattern, image gallery with timestamped filenames
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
|
||||
|
||||
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||
|
||||
**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 |
|
||||
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||
| direwolf | APRS | TNC modem for packet radio |
|
||||
|
||||
### Frontend Structure
|
||||
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
||||
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||
- Data persisted via `./data:/app/data` volume mount
|
||||
|
||||
### Configuration
|
||||
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
||||
- 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.
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"version": "2026-02-22_17194a71",
|
||||
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||
}
|
||||
@@ -459,7 +459,7 @@ SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
# Weather satellite settings
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 30.0)
|
||||
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000)
|
||||
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
|
||||
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
|
||||
|
||||
+375
-104
@@ -4,18 +4,18 @@ import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger('intercept.oui')
|
||||
logger = logging.getLogger("intercept.oui")
|
||||
|
||||
|
||||
def load_oui_database() -> dict[str, str] | None:
|
||||
"""Load OUI database from external JSON file, with fallback to built-in."""
|
||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
||||
oui_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "oui_database.json")
|
||||
try:
|
||||
if os.path.exists(oui_file):
|
||||
with open(oui_file) as f:
|
||||
data = json.load(f)
|
||||
# Remove comment fields
|
||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
||||
return {k: v for k, v in data.items() if not k.startswith("_")}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading oui_database.json: {e}, using built-in database")
|
||||
return None # Will fall back to built-in
|
||||
@@ -24,143 +24,414 @@ def load_oui_database() -> dict[str, str] | None:
|
||||
def get_manufacturer(mac: str) -> str:
|
||||
"""Look up manufacturer from MAC address OUI."""
|
||||
prefix = mac[:8].upper()
|
||||
return OUI_DATABASE.get(prefix, 'Unknown')
|
||||
return OUI_DATABASE.get(prefix, "Unknown")
|
||||
|
||||
|
||||
# OUI Database for manufacturer lookup (expanded)
|
||||
OUI_DATABASE = {
|
||||
# Apple (extensive list)
|
||||
'00:25:DB': 'Apple', '04:52:F3': 'Apple', '0C:3E:9F': 'Apple', '10:94:BB': 'Apple',
|
||||
'14:99:E2': 'Apple', '20:78:F0': 'Apple', '28:6A:BA': 'Apple', '3C:22:FB': 'Apple',
|
||||
'40:98:AD': 'Apple', '48:D7:05': 'Apple', '4C:57:CA': 'Apple', '54:4E:90': 'Apple',
|
||||
'5C:97:F3': 'Apple', '60:F8:1D': 'Apple', '68:DB:CA': 'Apple', '70:56:81': 'Apple',
|
||||
'78:7B:8A': 'Apple', '7C:D1:C3': 'Apple', '84:FC:FE': 'Apple', '8C:2D:AA': 'Apple',
|
||||
'90:B0:ED': 'Apple', '98:01:A7': 'Apple', '98:D6:BB': 'Apple', 'A4:D1:D2': 'Apple',
|
||||
'AC:BC:32': 'Apple', 'B0:34:95': 'Apple', 'B8:C1:11': 'Apple', 'C8:69:CD': 'Apple',
|
||||
'D0:03:4B': 'Apple', 'DC:A9:04': 'Apple', 'E0:C7:67': 'Apple', 'F0:18:98': 'Apple',
|
||||
'F4:5C:89': 'Apple', '78:4F:43': 'Apple', '00:CD:FE': 'Apple', '04:4B:ED': 'Apple',
|
||||
'04:D3:CF': 'Apple', '08:66:98': 'Apple', '0C:74:C2': 'Apple', '10:DD:B1': 'Apple',
|
||||
'14:10:9F': 'Apple', '18:EE:69': 'Apple', '1C:36:BB': 'Apple', '24:A0:74': 'Apple',
|
||||
'28:37:37': 'Apple', '2C:BE:08': 'Apple', '34:08:BC': 'Apple', '38:C9:86': 'Apple',
|
||||
'3C:06:30': 'Apple', '44:D8:84': 'Apple', '48:A9:1C': 'Apple', '4C:32:75': 'Apple',
|
||||
'50:32:37': 'Apple', '54:26:96': 'Apple', '58:B0:35': 'Apple', '5C:F7:E6': 'Apple',
|
||||
'64:A3:CB': 'Apple', '68:FE:F7': 'Apple', '6C:4D:73': 'Apple', '70:DE:E2': 'Apple',
|
||||
'74:E2:F5': 'Apple', '78:67:D7': 'Apple', '7C:04:D0': 'Apple', '80:E6:50': 'Apple',
|
||||
'84:78:8B': 'Apple', '88:66:A5': 'Apple', '8C:85:90': 'Apple', '94:E9:6A': 'Apple',
|
||||
'9C:F4:8E': 'Apple', 'A0:99:9B': 'Apple', 'A4:83:E7': 'Apple', 'A8:5C:2C': 'Apple',
|
||||
'AC:1F:74': 'Apple', 'B0:19:C6': 'Apple', 'B4:F1:DA': 'Apple', 'BC:52:B7': 'Apple',
|
||||
'C0:A5:3E': 'Apple', 'C4:B3:01': 'Apple', 'CC:20:E8': 'Apple', 'D0:C5:F3': 'Apple',
|
||||
'D4:61:9D': 'Apple', 'D8:1C:79': 'Apple', 'E0:5F:45': 'Apple', 'E4:C6:3D': 'Apple',
|
||||
'F0:B4:79': 'Apple', 'F4:0F:24': 'Apple', 'F8:4D:89': 'Apple', 'FC:D8:48': 'Apple',
|
||||
"00:25:DB": "Apple",
|
||||
"04:52:F3": "Apple",
|
||||
"0C:3E:9F": "Apple",
|
||||
"10:94:BB": "Apple",
|
||||
"14:99:E2": "Apple",
|
||||
"20:78:F0": "Apple",
|
||||
"28:6A:BA": "Apple",
|
||||
"3C:22:FB": "Apple",
|
||||
"40:98:AD": "Apple",
|
||||
"48:D7:05": "Apple",
|
||||
"4C:57:CA": "Apple",
|
||||
"54:4E:90": "Apple",
|
||||
"5C:97:F3": "Apple",
|
||||
"60:F8:1D": "Apple",
|
||||
"68:DB:CA": "Apple",
|
||||
"70:56:81": "Apple",
|
||||
"78:7B:8A": "Apple",
|
||||
"7C:D1:C3": "Apple",
|
||||
"84:FC:FE": "Apple",
|
||||
"8C:2D:AA": "Apple",
|
||||
"90:B0:ED": "Apple",
|
||||
"98:01:A7": "Apple",
|
||||
"98:D6:BB": "Apple",
|
||||
"A4:D1:D2": "Apple",
|
||||
"AC:BC:32": "Apple",
|
||||
"B0:34:95": "Apple",
|
||||
"B8:C1:11": "Apple",
|
||||
"C8:69:CD": "Apple",
|
||||
"D0:03:4B": "Apple",
|
||||
"DC:A9:04": "Apple",
|
||||
"E0:C7:67": "Apple",
|
||||
"F0:18:98": "Apple",
|
||||
"F4:5C:89": "Apple",
|
||||
"78:4F:43": "Apple",
|
||||
"00:CD:FE": "Apple",
|
||||
"04:4B:ED": "Apple",
|
||||
"04:D3:CF": "Apple",
|
||||
"08:66:98": "Apple",
|
||||
"0C:74:C2": "Apple",
|
||||
"10:DD:B1": "Apple",
|
||||
"14:10:9F": "Apple",
|
||||
"18:EE:69": "Apple",
|
||||
"1C:36:BB": "Apple",
|
||||
"24:A0:74": "Apple",
|
||||
"28:37:37": "Apple",
|
||||
"2C:BE:08": "Apple",
|
||||
"34:08:BC": "Apple",
|
||||
"38:C9:86": "Apple",
|
||||
"3C:06:30": "Apple",
|
||||
"44:D8:84": "Apple",
|
||||
"48:A9:1C": "Apple",
|
||||
"4C:32:75": "Apple",
|
||||
"50:32:37": "Apple",
|
||||
"54:26:96": "Apple",
|
||||
"58:B0:35": "Apple",
|
||||
"5C:F7:E6": "Apple",
|
||||
"64:A3:CB": "Apple",
|
||||
"68:FE:F7": "Apple",
|
||||
"6C:4D:73": "Apple",
|
||||
"70:DE:E2": "Apple",
|
||||
"74:E2:F5": "Apple",
|
||||
"78:67:D7": "Apple",
|
||||
"7C:04:D0": "Apple",
|
||||
"80:E6:50": "Apple",
|
||||
"84:78:8B": "Apple",
|
||||
"88:66:A5": "Apple",
|
||||
"8C:85:90": "Apple",
|
||||
"94:E9:6A": "Apple",
|
||||
"9C:F4:8E": "Apple",
|
||||
"A0:99:9B": "Apple",
|
||||
"A4:83:E7": "Apple",
|
||||
"A8:5C:2C": "Apple",
|
||||
"AC:1F:74": "Apple",
|
||||
"B0:19:C6": "Apple",
|
||||
"B4:F1:DA": "Apple",
|
||||
"BC:52:B7": "Apple",
|
||||
"C0:A5:3E": "Apple",
|
||||
"C4:B3:01": "Apple",
|
||||
"CC:20:E8": "Apple",
|
||||
"D0:C5:F3": "Apple",
|
||||
"D4:61:9D": "Apple",
|
||||
"D8:1C:79": "Apple",
|
||||
"E0:5F:45": "Apple",
|
||||
"E4:C6:3D": "Apple",
|
||||
"F0:B4:79": "Apple",
|
||||
"F4:0F:24": "Apple",
|
||||
"F8:4D:89": "Apple",
|
||||
"FC:D8:48": "Apple",
|
||||
# Samsung
|
||||
'00:1B:66': 'Samsung', '00:21:19': 'Samsung', '00:26:37': 'Samsung', '5C:0A:5B': 'Samsung',
|
||||
'8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung', '38:2C:4A': 'Samsung', '00:1E:4C': 'Samsung',
|
||||
'00:12:47': 'Samsung', '00:15:99': 'Samsung', '00:17:D5': 'Samsung', '00:1D:F6': 'Samsung',
|
||||
'00:21:D1': 'Samsung', '00:24:54': 'Samsung', '00:26:5D': 'Samsung', '08:D4:2B': 'Samsung',
|
||||
'10:D5:42': 'Samsung', '14:49:E0': 'Samsung', '18:3A:2D': 'Samsung', '1C:66:AA': 'Samsung',
|
||||
'24:4B:81': 'Samsung', '28:98:7B': 'Samsung', '2C:AE:2B': 'Samsung', '30:96:FB': 'Samsung',
|
||||
'34:C3:AC': 'Samsung', '38:01:95': 'Samsung', '3C:5A:37': 'Samsung', '40:0E:85': 'Samsung',
|
||||
'44:4E:1A': 'Samsung', '4C:BC:A5': 'Samsung', '50:01:BB': 'Samsung', '50:A4:D0': 'Samsung',
|
||||
'54:88:0E': 'Samsung', '58:C3:8B': 'Samsung', '5C:2E:59': 'Samsung', '60:D0:A9': 'Samsung',
|
||||
'64:B3:10': 'Samsung', '68:48:98': 'Samsung', '6C:2F:2C': 'Samsung', '70:F9:27': 'Samsung',
|
||||
'74:45:8A': 'Samsung', '78:47:1D': 'Samsung', '7C:0B:C6': 'Samsung', '84:11:9E': 'Samsung',
|
||||
'88:32:9B': 'Samsung', '8C:77:12': 'Samsung', '90:18:7C': 'Samsung', '94:35:0A': 'Samsung',
|
||||
'98:52:B1': 'Samsung', '9C:02:98': 'Samsung', 'A0:0B:BA': 'Samsung', 'A4:7B:85': 'Samsung',
|
||||
'A8:06:00': 'Samsung', 'AC:5F:3E': 'Samsung', 'B0:72:BF': 'Samsung', 'B4:79:A7': 'Samsung',
|
||||
'BC:44:86': 'Samsung', 'C0:97:27': 'Samsung', 'C4:42:02': 'Samsung', 'CC:07:AB': 'Samsung',
|
||||
'D0:22:BE': 'Samsung', 'D4:87:D8': 'Samsung', 'D8:90:E8': 'Samsung', 'E4:7C:F9': 'Samsung',
|
||||
'E8:50:8B': 'Samsung', 'F0:25:B7': 'Samsung', 'F4:7B:5E': 'Samsung', 'FC:A1:3E': 'Samsung',
|
||||
"00:1B:66": "Samsung",
|
||||
"00:21:19": "Samsung",
|
||||
"00:26:37": "Samsung",
|
||||
"5C:0A:5B": "Samsung",
|
||||
"8C:71:F8": "Samsung",
|
||||
"C4:73:1E": "Samsung",
|
||||
"38:2C:4A": "Samsung",
|
||||
"00:1E:4C": "Samsung",
|
||||
"00:12:47": "Samsung",
|
||||
"00:15:99": "Samsung",
|
||||
"00:17:D5": "Samsung",
|
||||
"00:1D:F6": "Samsung",
|
||||
"00:21:D1": "Samsung",
|
||||
"00:24:54": "Samsung",
|
||||
"00:26:5D": "Samsung",
|
||||
"08:D4:2B": "Samsung",
|
||||
"10:D5:42": "Samsung",
|
||||
"14:49:E0": "Samsung",
|
||||
"18:3A:2D": "Samsung",
|
||||
"1C:66:AA": "Samsung",
|
||||
"24:4B:81": "Samsung",
|
||||
"28:98:7B": "Samsung",
|
||||
"2C:AE:2B": "Samsung",
|
||||
"30:96:FB": "Samsung",
|
||||
"34:C3:AC": "Samsung",
|
||||
"38:01:95": "Samsung",
|
||||
"3C:5A:37": "Samsung",
|
||||
"40:0E:85": "Samsung",
|
||||
"44:4E:1A": "Samsung",
|
||||
"4C:BC:A5": "Samsung",
|
||||
"50:01:BB": "Samsung",
|
||||
"50:A4:D0": "Samsung",
|
||||
"54:88:0E": "Samsung",
|
||||
"58:C3:8B": "Samsung",
|
||||
"5C:2E:59": "Samsung",
|
||||
"60:D0:A9": "Samsung",
|
||||
"64:B3:10": "Samsung",
|
||||
"68:48:98": "Samsung",
|
||||
"6C:2F:2C": "Samsung",
|
||||
"70:F9:27": "Samsung",
|
||||
"74:45:8A": "Samsung",
|
||||
"78:47:1D": "Samsung",
|
||||
"7C:0B:C6": "Samsung",
|
||||
"84:11:9E": "Samsung",
|
||||
"88:32:9B": "Samsung",
|
||||
"8C:77:12": "Samsung",
|
||||
"90:18:7C": "Samsung",
|
||||
"94:35:0A": "Samsung",
|
||||
"98:52:B1": "Samsung",
|
||||
"9C:02:98": "Samsung",
|
||||
"A0:0B:BA": "Samsung",
|
||||
"A4:7B:85": "Samsung",
|
||||
"A8:06:00": "Samsung",
|
||||
"AC:5F:3E": "Samsung",
|
||||
"B0:72:BF": "Samsung",
|
||||
"B4:79:A7": "Samsung",
|
||||
"BC:44:86": "Samsung",
|
||||
"C0:97:27": "Samsung",
|
||||
"C4:42:02": "Samsung",
|
||||
"CC:07:AB": "Samsung",
|
||||
"D0:22:BE": "Samsung",
|
||||
"D4:87:D8": "Samsung",
|
||||
"D8:90:E8": "Samsung",
|
||||
"E4:7C:F9": "Samsung",
|
||||
"E8:50:8B": "Samsung",
|
||||
"F0:25:B7": "Samsung",
|
||||
"F4:7B:5E": "Samsung",
|
||||
"FC:A1:3E": "Samsung",
|
||||
# Google
|
||||
'54:60:09': 'Google', '00:1A:11': 'Google', 'F4:F5:D8': 'Google', '94:EB:2C': 'Google',
|
||||
'64:B5:C6': 'Google', '3C:5A:B4': 'Google', 'F8:8F:CA': 'Google', '20:DF:B9': 'Google',
|
||||
'54:27:1E': 'Google', '58:CB:52': 'Google', 'A4:77:33': 'Google', 'F4:0E:22': 'Google',
|
||||
"54:60:09": "Google",
|
||||
"00:1A:11": "Google",
|
||||
"F4:F5:D8": "Google",
|
||||
"94:EB:2C": "Google",
|
||||
"64:B5:C6": "Google",
|
||||
"3C:5A:B4": "Google",
|
||||
"F8:8F:CA": "Google",
|
||||
"20:DF:B9": "Google",
|
||||
"54:27:1E": "Google",
|
||||
"58:CB:52": "Google",
|
||||
"A4:77:33": "Google",
|
||||
"F4:0E:22": "Google",
|
||||
# Sony
|
||||
'00:13:A9': 'Sony', '00:1D:28': 'Sony', '00:24:BE': 'Sony', '04:5D:4B': 'Sony',
|
||||
'08:A9:5A': 'Sony', '10:4F:A8': 'Sony', '24:21:AB': 'Sony', '30:52:CB': 'Sony',
|
||||
'40:B8:37': 'Sony', '58:48:22': 'Sony', '70:9E:29': 'Sony', '84:00:D2': 'Sony',
|
||||
'AC:9B:0A': 'Sony', 'B4:52:7D': 'Sony', 'BC:60:A7': 'Sony', 'FC:0F:E6': 'Sony',
|
||||
"00:13:A9": "Sony",
|
||||
"00:1D:28": "Sony",
|
||||
"00:24:BE": "Sony",
|
||||
"04:5D:4B": "Sony",
|
||||
"08:A9:5A": "Sony",
|
||||
"10:4F:A8": "Sony",
|
||||
"24:21:AB": "Sony",
|
||||
"30:52:CB": "Sony",
|
||||
"40:B8:37": "Sony",
|
||||
"58:48:22": "Sony",
|
||||
"70:9E:29": "Sony",
|
||||
"84:00:D2": "Sony",
|
||||
"AC:9B:0A": "Sony",
|
||||
"B4:52:7D": "Sony",
|
||||
"BC:60:A7": "Sony",
|
||||
"FC:0F:E6": "Sony",
|
||||
# Bose
|
||||
'00:0C:8A': 'Bose', '04:52:C7': 'Bose', '08:DF:1F': 'Bose', '2C:41:A1': 'Bose',
|
||||
'4C:87:5D': 'Bose', '60:AB:D2': 'Bose', '88:C9:E8': 'Bose', 'D8:9C:67': 'Bose',
|
||||
"00:0C:8A": "Bose",
|
||||
"04:52:C7": "Bose",
|
||||
"08:DF:1F": "Bose",
|
||||
"2C:41:A1": "Bose",
|
||||
"4C:87:5D": "Bose",
|
||||
"60:AB:D2": "Bose",
|
||||
"88:C9:E8": "Bose",
|
||||
"D8:9C:67": "Bose",
|
||||
# JBL/Harman
|
||||
'00:1D:DF': 'JBL', '08:AE:D6': 'JBL', '20:3C:AE': 'JBL', '44:5E:F3': 'JBL',
|
||||
'50:C9:71': 'JBL', '74:5E:1C': 'JBL', '88:C6:26': 'JBL', 'AC:12:2F': 'JBL',
|
||||
"00:1D:DF": "JBL",
|
||||
"08:AE:D6": "JBL",
|
||||
"20:3C:AE": "JBL",
|
||||
"44:5E:F3": "JBL",
|
||||
"50:C9:71": "JBL",
|
||||
"74:5E:1C": "JBL",
|
||||
"88:C6:26": "JBL",
|
||||
"AC:12:2F": "JBL",
|
||||
# Beats (Apple subsidiary)
|
||||
'00:61:71': 'Beats', '48:D6:D5': 'Beats', '9C:64:8B': 'Beats', 'A4:E9:75': 'Beats',
|
||||
"00:61:71": "Beats",
|
||||
"48:D6:D5": "Beats",
|
||||
"9C:64:8B": "Beats",
|
||||
"A4:E9:75": "Beats",
|
||||
# Jabra/GN Audio
|
||||
'00:13:17': 'Jabra', '1C:48:F9': 'Jabra', '50:C2:ED': 'Jabra', '70:BF:92': 'Jabra',
|
||||
'74:5C:4B': 'Jabra', '94:16:25': 'Jabra', 'D0:81:7A': 'Jabra', 'E8:EE:CC': 'Jabra',
|
||||
"00:13:17": "Jabra",
|
||||
"1C:48:F9": "Jabra",
|
||||
"50:C2:ED": "Jabra",
|
||||
"70:BF:92": "Jabra",
|
||||
"74:5C:4B": "Jabra",
|
||||
"94:16:25": "Jabra",
|
||||
"D0:81:7A": "Jabra",
|
||||
"E8:EE:CC": "Jabra",
|
||||
# Sennheiser
|
||||
'00:1B:66': 'Sennheiser', '00:22:27': 'Sennheiser', 'B8:AD:3E': 'Sennheiser',
|
||||
"00:1B:66": "Sennheiser",
|
||||
"00:22:27": "Sennheiser",
|
||||
"B8:AD:3E": "Sennheiser",
|
||||
# Xiaomi
|
||||
'04:CF:8C': 'Xiaomi', '0C:1D:AF': 'Xiaomi', '10:2A:B3': 'Xiaomi', '18:59:36': 'Xiaomi',
|
||||
'20:47:DA': 'Xiaomi', '28:6C:07': 'Xiaomi', '34:CE:00': 'Xiaomi', '38:A4:ED': 'Xiaomi',
|
||||
'44:23:7C': 'Xiaomi', '50:64:2B': 'Xiaomi', '58:44:98': 'Xiaomi', '64:09:80': 'Xiaomi',
|
||||
'74:23:44': 'Xiaomi', '78:02:F8': 'Xiaomi', '7C:1C:4E': 'Xiaomi', '84:F3:EB': 'Xiaomi',
|
||||
'8C:BE:BE': 'Xiaomi', '98:FA:E3': 'Xiaomi', 'A4:77:58': 'Xiaomi', 'AC:C1:EE': 'Xiaomi',
|
||||
'B0:E2:35': 'Xiaomi', 'C4:0B:CB': 'Xiaomi', 'C8:47:8C': 'Xiaomi', 'D4:97:0B': 'Xiaomi',
|
||||
'E4:46:DA': 'Xiaomi', 'F0:B4:29': 'Xiaomi', 'FC:64:BA': 'Xiaomi',
|
||||
"04:CF:8C": "Xiaomi",
|
||||
"0C:1D:AF": "Xiaomi",
|
||||
"10:2A:B3": "Xiaomi",
|
||||
"18:59:36": "Xiaomi",
|
||||
"20:47:DA": "Xiaomi",
|
||||
"28:6C:07": "Xiaomi",
|
||||
"34:CE:00": "Xiaomi",
|
||||
"38:A4:ED": "Xiaomi",
|
||||
"44:23:7C": "Xiaomi",
|
||||
"50:64:2B": "Xiaomi",
|
||||
"58:44:98": "Xiaomi",
|
||||
"64:09:80": "Xiaomi",
|
||||
"74:23:44": "Xiaomi",
|
||||
"78:02:F8": "Xiaomi",
|
||||
"7C:1C:4E": "Xiaomi",
|
||||
"84:F3:EB": "Xiaomi",
|
||||
"8C:BE:BE": "Xiaomi",
|
||||
"98:FA:E3": "Xiaomi",
|
||||
"A4:77:58": "Xiaomi",
|
||||
"AC:C1:EE": "Xiaomi",
|
||||
"B0:E2:35": "Xiaomi",
|
||||
"C4:0B:CB": "Xiaomi",
|
||||
"C8:47:8C": "Xiaomi",
|
||||
"D4:97:0B": "Xiaomi",
|
||||
"E4:46:DA": "Xiaomi",
|
||||
"F0:B4:29": "Xiaomi",
|
||||
"FC:64:BA": "Xiaomi",
|
||||
# Huawei
|
||||
'00:18:82': 'Huawei', '00:1E:10': 'Huawei', '00:25:68': 'Huawei', '04:B0:E7': 'Huawei',
|
||||
'08:63:61': 'Huawei', '10:1B:54': 'Huawei', '18:DE:D7': 'Huawei', '20:A6:80': 'Huawei',
|
||||
'28:31:52': 'Huawei', '34:12:98': 'Huawei', '3C:47:11': 'Huawei', '48:00:31': 'Huawei',
|
||||
'4C:50:77': 'Huawei', '5C:7D:5E': 'Huawei', '60:DE:44': 'Huawei', '70:72:3C': 'Huawei',
|
||||
'78:F5:57': 'Huawei', '80:B6:86': 'Huawei', '88:53:D4': 'Huawei', '94:04:9C': 'Huawei',
|
||||
'A4:99:47': 'Huawei', 'B4:15:13': 'Huawei', 'BC:76:70': 'Huawei', 'C8:D1:5E': 'Huawei',
|
||||
'DC:D2:FC': 'Huawei', 'E4:68:A3': 'Huawei', 'F4:63:1F': 'Huawei',
|
||||
"00:18:82": "Huawei",
|
||||
"00:1E:10": "Huawei",
|
||||
"00:25:68": "Huawei",
|
||||
"04:B0:E7": "Huawei",
|
||||
"08:63:61": "Huawei",
|
||||
"10:1B:54": "Huawei",
|
||||
"18:DE:D7": "Huawei",
|
||||
"20:A6:80": "Huawei",
|
||||
"28:31:52": "Huawei",
|
||||
"34:12:98": "Huawei",
|
||||
"3C:47:11": "Huawei",
|
||||
"48:00:31": "Huawei",
|
||||
"4C:50:77": "Huawei",
|
||||
"5C:7D:5E": "Huawei",
|
||||
"60:DE:44": "Huawei",
|
||||
"70:72:3C": "Huawei",
|
||||
"78:F5:57": "Huawei",
|
||||
"80:B6:86": "Huawei",
|
||||
"88:53:D4": "Huawei",
|
||||
"94:04:9C": "Huawei",
|
||||
"A4:99:47": "Huawei",
|
||||
"B4:15:13": "Huawei",
|
||||
"BC:76:70": "Huawei",
|
||||
"C8:D1:5E": "Huawei",
|
||||
"DC:D2:FC": "Huawei",
|
||||
"E4:68:A3": "Huawei",
|
||||
"F4:63:1F": "Huawei",
|
||||
# OnePlus/BBK
|
||||
'64:A2:F9': 'OnePlus', 'C0:EE:FB': 'OnePlus', '94:65:2D': 'OnePlus',
|
||||
"64:A2:F9": "OnePlus",
|
||||
"C0:EE:FB": "OnePlus",
|
||||
"94:65:2D": "OnePlus",
|
||||
# Fitbit
|
||||
'2C:09:4D': 'Fitbit', 'C4:D9:87': 'Fitbit', 'E4:88:6D': 'Fitbit',
|
||||
"2C:09:4D": "Fitbit",
|
||||
"C4:D9:87": "Fitbit",
|
||||
"E4:88:6D": "Fitbit",
|
||||
# Garmin
|
||||
'00:1C:D1': 'Garmin', 'C4:AC:59': 'Garmin', 'E8:0F:C8': 'Garmin',
|
||||
"00:1C:D1": "Garmin",
|
||||
"C4:AC:59": "Garmin",
|
||||
"E8:0F:C8": "Garmin",
|
||||
# Microsoft
|
||||
'00:50:F2': 'Microsoft', '28:18:78': 'Microsoft', '60:45:BD': 'Microsoft',
|
||||
'7C:1E:52': 'Microsoft', '98:5F:D3': 'Microsoft', 'B4:0E:DE': 'Microsoft',
|
||||
"00:50:F2": "Microsoft",
|
||||
"28:18:78": "Microsoft",
|
||||
"60:45:BD": "Microsoft",
|
||||
"7C:1E:52": "Microsoft",
|
||||
"98:5F:D3": "Microsoft",
|
||||
"B4:0E:DE": "Microsoft",
|
||||
# Intel
|
||||
'00:1B:21': 'Intel', '00:1C:C0': 'Intel', '00:1E:64': 'Intel', '00:21:5C': 'Intel',
|
||||
'08:D4:0C': 'Intel', '18:1D:EA': 'Intel', '34:02:86': 'Intel', '40:74:E0': 'Intel',
|
||||
'48:51:B7': 'Intel', '58:A0:23': 'Intel', '64:D4:DA': 'Intel', '80:19:34': 'Intel',
|
||||
'8C:8D:28': 'Intel', 'A4:4E:31': 'Intel', 'B4:6B:FC': 'Intel', 'C8:D0:83': 'Intel',
|
||||
"00:1B:21": "Intel",
|
||||
"00:1C:C0": "Intel",
|
||||
"00:1E:64": "Intel",
|
||||
"00:21:5C": "Intel",
|
||||
"08:D4:0C": "Intel",
|
||||
"18:1D:EA": "Intel",
|
||||
"34:02:86": "Intel",
|
||||
"40:74:E0": "Intel",
|
||||
"48:51:B7": "Intel",
|
||||
"58:A0:23": "Intel",
|
||||
"64:D4:DA": "Intel",
|
||||
"80:19:34": "Intel",
|
||||
"8C:8D:28": "Intel",
|
||||
"A4:4E:31": "Intel",
|
||||
"B4:6B:FC": "Intel",
|
||||
"C8:D0:83": "Intel",
|
||||
# Qualcomm/Atheros
|
||||
'00:03:7F': 'Qualcomm', '00:24:E4': 'Qualcomm', '04:F0:21': 'Qualcomm',
|
||||
'1C:4B:D6': 'Qualcomm', '88:71:B1': 'Qualcomm', 'A0:65:18': 'Qualcomm',
|
||||
"00:03:7F": "Qualcomm",
|
||||
"00:24:E4": "Qualcomm",
|
||||
"04:F0:21": "Qualcomm",
|
||||
"1C:4B:D6": "Qualcomm",
|
||||
"88:71:B1": "Qualcomm",
|
||||
"A0:65:18": "Qualcomm",
|
||||
# Broadcom
|
||||
'00:10:18': 'Broadcom', '00:1A:2B': 'Broadcom', '20:10:7A': 'Broadcom',
|
||||
"00:10:18": "Broadcom",
|
||||
"00:1A:2B": "Broadcom",
|
||||
"20:10:7A": "Broadcom",
|
||||
# Realtek
|
||||
'00:0A:EB': 'Realtek', '00:E0:4C': 'Realtek', '48:02:2A': 'Realtek',
|
||||
'52:54:00': 'Realtek', '80:EA:96': 'Realtek',
|
||||
"00:0A:EB": "Realtek",
|
||||
"00:E0:4C": "Realtek",
|
||||
"48:02:2A": "Realtek",
|
||||
"52:54:00": "Realtek",
|
||||
"80:EA:96": "Realtek",
|
||||
# Logitech
|
||||
'00:1F:20': 'Logitech', '34:88:5D': 'Logitech', '6C:B7:49': 'Logitech',
|
||||
"00:1F:20": "Logitech",
|
||||
"34:88:5D": "Logitech",
|
||||
"6C:B7:49": "Logitech",
|
||||
# Lenovo
|
||||
'00:09:2D': 'Lenovo', '28:D2:44': 'Lenovo', '54:EE:75': 'Lenovo', '98:FA:9B': 'Lenovo',
|
||||
"00:09:2D": "Lenovo",
|
||||
"28:D2:44": "Lenovo",
|
||||
"54:EE:75": "Lenovo",
|
||||
"98:FA:9B": "Lenovo",
|
||||
# Dell
|
||||
'00:14:22': 'Dell', '00:1A:A0': 'Dell', '18:DB:F2': 'Dell', '34:17:EB': 'Dell',
|
||||
'78:2B:CB': 'Dell', 'A4:BA:DB': 'Dell', 'E4:B9:7A': 'Dell',
|
||||
"00:14:22": "Dell",
|
||||
"00:1A:A0": "Dell",
|
||||
"18:DB:F2": "Dell",
|
||||
"34:17:EB": "Dell",
|
||||
"78:2B:CB": "Dell",
|
||||
"A4:BA:DB": "Dell",
|
||||
"E4:B9:7A": "Dell",
|
||||
# HP
|
||||
'00:0F:61': 'HP', '00:14:C2': 'HP', '10:1F:74': 'HP', '28:80:23': 'HP',
|
||||
'38:63:BB': 'HP', '5C:B9:01': 'HP', '80:CE:62': 'HP', 'A0:D3:C1': 'HP',
|
||||
"00:0F:61": "HP",
|
||||
"00:14:C2": "HP",
|
||||
"10:1F:74": "HP",
|
||||
"28:80:23": "HP",
|
||||
"38:63:BB": "HP",
|
||||
"5C:B9:01": "HP",
|
||||
"80:CE:62": "HP",
|
||||
"A0:D3:C1": "HP",
|
||||
# Tile
|
||||
'F8:E4:E3': 'Tile', 'C4:E7:BE': 'Tile', 'DC:54:D7': 'Tile', 'E4:B0:21': 'Tile',
|
||||
"F8:E4:E3": "Tile",
|
||||
"C4:E7:BE": "Tile",
|
||||
"DC:54:D7": "Tile",
|
||||
"E4:B0:21": "Tile",
|
||||
# Raspberry Pi
|
||||
'B8:27:EB': 'Raspberry Pi', 'DC:A6:32': 'Raspberry Pi', 'E4:5F:01': 'Raspberry Pi',
|
||||
"B8:27:EB": "Raspberry Pi",
|
||||
"DC:A6:32": "Raspberry Pi",
|
||||
"E4:5F:01": "Raspberry Pi",
|
||||
# Amazon
|
||||
'00:FC:8B': 'Amazon', '10:CE:A9': 'Amazon', '34:D2:70': 'Amazon', '40:B4:CD': 'Amazon',
|
||||
'44:65:0D': 'Amazon', '68:54:FD': 'Amazon', '74:C2:46': 'Amazon', '84:D6:D0': 'Amazon',
|
||||
'A0:02:DC': 'Amazon', 'AC:63:BE': 'Amazon', 'B4:7C:9C': 'Amazon', 'FC:65:DE': 'Amazon',
|
||||
"00:FC:8B": "Amazon",
|
||||
"10:CE:A9": "Amazon",
|
||||
"34:D2:70": "Amazon",
|
||||
"40:B4:CD": "Amazon",
|
||||
"44:65:0D": "Amazon",
|
||||
"68:54:FD": "Amazon",
|
||||
"74:C2:46": "Amazon",
|
||||
"84:D6:D0": "Amazon",
|
||||
"A0:02:DC": "Amazon",
|
||||
"AC:63:BE": "Amazon",
|
||||
"B4:7C:9C": "Amazon",
|
||||
"FC:65:DE": "Amazon",
|
||||
# Skullcandy
|
||||
'00:01:00': 'Skullcandy', '88:E6:03': 'Skullcandy',
|
||||
"00:01:00": "Skullcandy",
|
||||
"88:E6:03": "Skullcandy",
|
||||
# Bang & Olufsen
|
||||
'00:21:3E': 'Bang & Olufsen', '78:C5:E5': 'Bang & Olufsen',
|
||||
"00:21:3E": "Bang & Olufsen",
|
||||
"78:C5:E5": "Bang & Olufsen",
|
||||
# Audio-Technica
|
||||
'A0:E9:DB': 'Audio-Technica', 'EC:81:93': 'Audio-Technica',
|
||||
"A0:E9:DB": "Audio-Technica",
|
||||
"EC:81:93": "Audio-Technica",
|
||||
# Plantronics/Poly
|
||||
'00:1D:DF': 'Plantronics', 'B0:B4:48': 'Plantronics', 'E8:FC:AF': 'Plantronics',
|
||||
"00:1D:DF": "Plantronics",
|
||||
"B0:B4:48": "Plantronics",
|
||||
"E8:FC:AF": "Plantronics",
|
||||
# Anker
|
||||
'AC:89:95': 'Anker', 'E8:AB:FA': 'Anker',
|
||||
"AC:89:95": "Anker",
|
||||
"E8:AB:FA": "Anker",
|
||||
# Misc/Generic
|
||||
'00:00:0A': 'Omron', '00:1A:7D': 'Cyber-Blue', '00:1E:3D': 'Alps Electric',
|
||||
'00:0B:57': 'Silicon Wave', '00:02:72': 'CC&C',
|
||||
"00:00:0A": "Omron",
|
||||
"00:1A:7D": "Cyber-Blue",
|
||||
"00:1E:3D": "Alps Electric",
|
||||
"00:0B:57": "Silicon Wave",
|
||||
"00:02:72": "CC&C",
|
||||
}
|
||||
|
||||
# Try to load from external file (easier to update)
|
||||
|
||||
@@ -16,8 +16,6 @@ services:
|
||||
build: .
|
||||
pull_policy: never
|
||||
container_name: intercept
|
||||
profiles:
|
||||
- basic
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||
|
||||
+144
@@ -539,6 +539,150 @@ Enable "Show All Agents" to aggregate data from all registered agents simultaneo
|
||||
|
||||
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
|
||||
|
||||
## Webhooks & Notifications
|
||||
|
||||
INTERCEPT has a built-in alert engine that fires webhooks when decoded events match configurable rules. This lets you forward pager messages (or events from any other mode) to Discord, Slack, n8n, Home Assistant, or any HTTP endpoint.
|
||||
|
||||
### How it works
|
||||
|
||||
1. You configure **alert rules** via the Alerts UI — each rule defines which mode and event type to watch, optional match criteria, and a severity level.
|
||||
2. When an incoming event matches a rule, INTERCEPT stores it in the alert log and POSTs a JSON payload to your configured webhook URL.
|
||||
3. All modes are supported: pager, sensor, ADS-B, AIS, ACARS, WiFi, Bluetooth, and more.
|
||||
|
||||
### Enable the webhook
|
||||
|
||||
Set these environment variables in your `.env` file or `docker-compose.yml`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ALERT_WEBHOOK_URL` | _(empty)_ | URL to POST alert payloads to |
|
||||
| `ALERT_WEBHOOK_SECRET` | _(empty)_ | Optional token sent as `X-Alert-Token` header |
|
||||
| `ALERT_WEBHOOK_TIMEOUT` | `5` | HTTP timeout in seconds |
|
||||
|
||||
**Local install (`.env`):**
|
||||
```env
|
||||
ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts
|
||||
ALERT_WEBHOOK_SECRET=mysecrettoken
|
||||
```
|
||||
|
||||
**Docker (`.env` or `docker-compose.yml` environment block):**
|
||||
```env
|
||||
ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts
|
||||
ALERT_WEBHOOK_SECRET=mysecrettoken
|
||||
```
|
||||
|
||||
### Create an alert rule
|
||||
|
||||
1. Open the **Alerts** panel in INTERCEPT
|
||||
2. Click **New Rule**
|
||||
3. Configure:
|
||||
- **Mode**: `pager` (or any other mode, or leave blank to match all)
|
||||
- **Event type**: `message` for pager decodes (or blank to match all event types)
|
||||
- **Match criteria**: leave empty to forward everything, or add filters (e.g. capcode equals `1234567`, or message contains `FIRE`)
|
||||
- **Severity**: `low`, `medium`, or `high`
|
||||
4. Save and enable the rule
|
||||
|
||||
### Webhook payload format
|
||||
|
||||
INTERCEPT sends a POST request with `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"rule_id": 1,
|
||||
"mode": "pager",
|
||||
"event_type": "message",
|
||||
"severity": "medium",
|
||||
"title": "My Pager Rule",
|
||||
"message": "message | 1234567",
|
||||
"created_at": "2026-04-13T10:00:00+00:00",
|
||||
"payload": {
|
||||
"mode": "pager",
|
||||
"event_type": "message",
|
||||
"event": {
|
||||
"capcode": "1234567",
|
||||
"message": "UNIT 4 RESPOND TO 123 MAIN ST",
|
||||
"type": "POCSAG1200"
|
||||
},
|
||||
"rule": { "id": 1, "name": "My Pager Rule" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sending to Discord
|
||||
|
||||
Discord webhooks expect a specific JSON format (`content`, `embeds`), so you need a small relay between INTERCEPT and Discord. Two options:
|
||||
|
||||
**Option A — No-code relay (recommended)**
|
||||
|
||||
Use [n8n](https://n8n.io), [Make](https://make.com), or [Pipedream](https://pipedream.com) to receive INTERCEPT's webhook and forward it to Discord with a custom message template. Point `ALERT_WEBHOOK_URL` at your workflow's ingest URL.
|
||||
|
||||
**Option B — Self-hosted Python relay**
|
||||
|
||||
Save this as `discord_relay.py` and run it alongside INTERCEPT:
|
||||
|
||||
```python
|
||||
from flask import Flask, request
|
||||
import urllib.request, json
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
|
||||
|
||||
@app.post("/relay")
|
||||
def relay():
|
||||
data = request.get_json(force=True)
|
||||
mode = data.get("mode", "unknown").upper()
|
||||
title = data.get("title", "Alert")
|
||||
message = data.get("message", "")
|
||||
event = data.get("payload", {}).get("event", {})
|
||||
|
||||
# Build a readable Discord message
|
||||
lines = [f"**[{mode}]** {title}", message]
|
||||
if event.get("capcode"):
|
||||
lines.append(f"Capcode: `{event['capcode']}`")
|
||||
if event.get("type"):
|
||||
lines.append(f"Protocol: {event['type']}")
|
||||
|
||||
payload = json.dumps({"content": "\n".join(lines)}).encode()
|
||||
req = urllib.request.Request(
|
||||
DISCORD_WEBHOOK_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
return "", 204
|
||||
|
||||
app.run(host="0.0.0.0", port=5051)
|
||||
```
|
||||
|
||||
Then set:
|
||||
```env
|
||||
ALERT_WEBHOOK_URL=http://localhost:5051/relay
|
||||
```
|
||||
|
||||
Run the relay: `python3 discord_relay.py`
|
||||
|
||||
The relay formats pager decodes as Discord messages like:
|
||||
|
||||
```
|
||||
[PAGER] My Pager Rule
|
||||
message | 1234567
|
||||
Capcode: `1234567`
|
||||
Protocol: POCSAG1200
|
||||
```
|
||||
|
||||
### Filtering specific capcodes
|
||||
|
||||
To only forward decodes from a specific capcode, set the rule's **Match criteria**:
|
||||
|
||||
| Field | Operator | Value |
|
||||
|-------|----------|-------|
|
||||
| `capcode` | equals | `1234567` |
|
||||
|
||||
Multiple rules can coexist — e.g. one rule for all pager traffic to a general Discord channel, and a second rule for emergency capcodes with `high` severity to a separate channel (using a second relay instance on a different port).
|
||||
|
||||
## Configuration
|
||||
|
||||
INTERCEPT can be configured via environment variables:
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
DMSP 5D-3 F16 (USA 172)
|
||||
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
|
||||
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
|
||||
METEOSAT-9 (MSG-2)
|
||||
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
|
||||
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
|
||||
DMSP 5D-3 F17 (USA 191)
|
||||
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
|
||||
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
|
||||
FENGYUN 3A
|
||||
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
|
||||
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
|
||||
GOES 14
|
||||
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
|
||||
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
|
||||
DMSP 5D-3 F18 (USA 210)
|
||||
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
|
||||
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
|
||||
EWS-G2 (GOES 15)
|
||||
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
|
||||
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
|
||||
COMS 1
|
||||
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
|
||||
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
|
||||
FENGYUN 3B
|
||||
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
|
||||
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
|
||||
SUOMI NPP
|
||||
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
|
||||
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
|
||||
METEOSAT-10 (MSG-3)
|
||||
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
|
||||
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
|
||||
METOP-B
|
||||
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
|
||||
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
|
||||
INSAT-3D
|
||||
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
|
||||
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
|
||||
FENGYUN 3C
|
||||
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
|
||||
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
|
||||
METEOR-M 2
|
||||
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
|
||||
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
|
||||
HIMAWARI-8
|
||||
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
|
||||
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
|
||||
FENGYUN 2G
|
||||
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
|
||||
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
|
||||
METEOSAT-11 (MSG-4)
|
||||
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
|
||||
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
|
||||
ELEKTRO-L 2
|
||||
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
|
||||
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
|
||||
INSAT-3DR
|
||||
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
|
||||
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
|
||||
HIMAWARI-9
|
||||
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
|
||||
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
|
||||
GOES 16
|
||||
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
|
||||
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
|
||||
FENGYUN 4A
|
||||
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
|
||||
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
|
||||
CYGFM05
|
||||
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
|
||||
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
|
||||
CYGFM04
|
||||
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
|
||||
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
|
||||
CYGFM02
|
||||
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
|
||||
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
|
||||
CYGFM01
|
||||
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
|
||||
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
|
||||
CYGFM08
|
||||
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
|
||||
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
|
||||
CYGFM07
|
||||
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
|
||||
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
|
||||
CYGFM03
|
||||
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
|
||||
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
|
||||
FENGYUN 3D
|
||||
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
|
||||
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
|
||||
NOAA 20 (JPSS-1)
|
||||
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
|
||||
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
|
||||
GOES 17
|
||||
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
|
||||
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
|
||||
FENGYUN 2H
|
||||
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
|
||||
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
|
||||
METOP-C
|
||||
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
|
||||
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
|
||||
GEO-KOMPSAT-2A
|
||||
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
|
||||
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
|
||||
METEOR-M2 2
|
||||
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
|
||||
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
|
||||
ARKTIKA-M 1
|
||||
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
|
||||
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
|
||||
FENGYUN 3E
|
||||
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
|
||||
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
|
||||
GOES 18
|
||||
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
|
||||
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
|
||||
NOAA 21 (JPSS-2)
|
||||
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
|
||||
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
|
||||
METEOSAT-12 (MTG-I1)
|
||||
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
|
||||
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
|
||||
TIANMU-1 03
|
||||
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
|
||||
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
|
||||
TIANMU-1 04
|
||||
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
|
||||
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
|
||||
TIANMU-1 05
|
||||
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
|
||||
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
|
||||
TIANMU-1 06
|
||||
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
|
||||
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
|
||||
FENGYUN 3G
|
||||
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
|
||||
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
|
||||
METEOR-M2 3
|
||||
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
|
||||
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
|
||||
TIANMU-1 07
|
||||
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
|
||||
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
|
||||
TIANMU-1 08
|
||||
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
|
||||
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
|
||||
TIANMU-1 09
|
||||
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
|
||||
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
|
||||
TIANMU-1 10
|
||||
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
|
||||
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
|
||||
FENGYUN 3F
|
||||
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
|
||||
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
|
||||
ARKTIKA-M 2
|
||||
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
|
||||
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
|
||||
TIANMU-1 11
|
||||
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
|
||||
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
|
||||
TIANMU-1 12
|
||||
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
|
||||
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
|
||||
TIANMU-1 13
|
||||
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
|
||||
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
|
||||
TIANMU-1 14
|
||||
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
|
||||
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
|
||||
TIANMU-1 19
|
||||
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
|
||||
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
|
||||
TIANMU-1 20
|
||||
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
|
||||
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
|
||||
TIANMU-1 21
|
||||
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
|
||||
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
|
||||
TIANMU-1 22
|
||||
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
|
||||
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
|
||||
TIANMU-1 15
|
||||
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
|
||||
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
|
||||
TIANMU-1 16
|
||||
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
|
||||
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
|
||||
TIANMU-1 17
|
||||
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
|
||||
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
|
||||
TIANMU-1 18
|
||||
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
|
||||
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
|
||||
INSAT-3DS
|
||||
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
|
||||
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
|
||||
METEOR-M2 4
|
||||
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
|
||||
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
|
||||
GOES 19
|
||||
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
|
||||
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
|
||||
FENGYUN 3H
|
||||
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
|
||||
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
|
||||
@@ -10,6 +10,7 @@ pytest-mock>=3.15.1
|
||||
ruff>=0.1.0
|
||||
black>=23.0.0
|
||||
mypy>=1.0.0
|
||||
pre-commit>=3.0.0
|
||||
|
||||
# Type stubs
|
||||
types-flask>=1.1.0
|
||||
|
||||
+46
-2
@@ -79,6 +79,7 @@ adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
adsb_active_device = None # Track which device index is being used
|
||||
adsb_active_sdr_type: str | None = None
|
||||
adsb_bias_t_active = False # Track if bias-t was enabled at start (for cleanup on stop)
|
||||
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
@@ -803,6 +804,41 @@ def adsb_status():
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft')
|
||||
def adsb_aircraft_export():
|
||||
"""Export current ADS-B aircraft data as JSON.
|
||||
|
||||
Returns a snapshot of all tracked aircraft suitable for integration
|
||||
with external tools. For SBS (BaseStation) format, connect directly
|
||||
to port 30003 which dump1090 exposes natively.
|
||||
|
||||
Query parameters:
|
||||
icao: Filter to a specific ICAO hex code (optional)
|
||||
military: 'true' to return only military aircraft (optional)
|
||||
|
||||
Returns:
|
||||
JSON with aircraft list and metadata.
|
||||
"""
|
||||
aircraft = dict(app_module.adsb_aircraft)
|
||||
|
||||
icao_filter = request.args.get('icao', '').upper()
|
||||
if icao_filter:
|
||||
aircraft = {k: v for k, v in aircraft.items() if k.upper() == icao_filter}
|
||||
|
||||
if request.args.get('military') == 'true':
|
||||
try:
|
||||
from utils.military_icao import is_military_icao
|
||||
aircraft = {k: v for k, v in aircraft.items() if is_military_icao(k)}
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'count': len(aircraft),
|
||||
'aircraft': list(aircraft.values()),
|
||||
'sbs_port': 30003, # dump1090 SBS stream for tools like Virtual Radar Server
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/session')
|
||||
def adsb_session():
|
||||
"""Get ADS-B session status and uptime."""
|
||||
@@ -824,7 +860,7 @@ def adsb_session():
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
@@ -956,6 +992,7 @@ def start_adsb():
|
||||
|
||||
# Build ADS-B decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
adsb_bias_t_active = bias_t
|
||||
cmd = builder.build_adsb_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain),
|
||||
@@ -1104,7 +1141,7 @@ def start_adsb():
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active
|
||||
data = request.get_json(silent=True) or {}
|
||||
stop_source = data.get('source')
|
||||
stopped_by = request.remote_addr
|
||||
@@ -1127,6 +1164,13 @@ def stop_adsb():
|
||||
clear_dump1090_pid()
|
||||
logger.info("ADS-B process stopped")
|
||||
|
||||
# Turn off bias-T if it was enabled at start — the hardware register
|
||||
# persists after the device is closed, so we must explicitly disable it.
|
||||
if adsb_bias_t_active and (adsb_active_sdr_type or 'rtlsdr') == 'rtlsdr':
|
||||
from utils.sdr.rtlsdr import disable_bias_t_via_rtl_biast
|
||||
disable_bias_t_via_rtl_biast(adsb_active_device or 0)
|
||||
adsb_bias_t_active = False
|
||||
|
||||
# Release device from registry
|
||||
if adsb_active_device is not None:
|
||||
app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
|
||||
|
||||
+39
-1
@@ -408,11 +408,24 @@ def start_ais():
|
||||
bias_t = data.get('bias_t', False)
|
||||
tcp_port = AIS_TCP_PORT
|
||||
|
||||
# Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110)
|
||||
udp_host = data.get('udp_host') or None
|
||||
udp_port = None
|
||||
if udp_host:
|
||||
try:
|
||||
udp_port = int(data.get('udp_port', 10110))
|
||||
if not 1 <= udp_port <= 65535:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
return api_error('Invalid udp_port (1-65535)', 400)
|
||||
|
||||
cmd = builder.build_ais_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain),
|
||||
bias_t=bias_t,
|
||||
tcp_port=tcp_port
|
||||
tcp_port=tcp_port,
|
||||
udp_host=udp_host,
|
||||
udp_port=udp_port,
|
||||
)
|
||||
|
||||
# Use the found AIS-catcher path
|
||||
@@ -535,6 +548,31 @@ def get_vessel_dsc(mmsi: str):
|
||||
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/vessels')
|
||||
def ais_vessels():
|
||||
"""Export current AIS vessel data as JSON.
|
||||
|
||||
Returns a snapshot of all tracked vessels suitable for integration
|
||||
with external tools (OpenCPN, ship tracking apps, etc.).
|
||||
|
||||
Query parameters:
|
||||
mmsi: Filter to a specific MMSI (optional)
|
||||
|
||||
Returns:
|
||||
JSON with vessel list and metadata.
|
||||
"""
|
||||
vessels = dict(app_module.ais_vessels)
|
||||
|
||||
mmsi_filter = request.args.get('mmsi')
|
||||
if mmsi_filter:
|
||||
vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)}
|
||||
|
||||
return jsonify({
|
||||
'count': len(vessels),
|
||||
'vessels': list(vessels.values()),
|
||||
})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
|
||||
+53
-60
@@ -9,49 +9,43 @@ from flask import Blueprint, request
|
||||
from utils.database import get_setting, set_setting
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
offline_bp = Blueprint("offline", __name__, url_prefix="/offline")
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
"offline.enabled": False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
"offline.assets_source": "local",
|
||||
"offline.fonts_source": "local",
|
||||
"offline.tile_provider": "cartodb_dark_cyan",
|
||||
"offline.tile_server_url": "",
|
||||
"offline.stadia_key": "",
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
'leaflet': [
|
||||
'static/vendor/leaflet/leaflet.js',
|
||||
'static/vendor/leaflet/leaflet.css'
|
||||
"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",
|
||||
],
|
||||
'chartjs': [
|
||||
'static/vendor/chartjs/chart.umd.min.js'
|
||||
"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",
|
||||
],
|
||||
'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'
|
||||
"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",
|
||||
],
|
||||
'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'
|
||||
],
|
||||
'leaflet_heat': [
|
||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||
]
|
||||
"leaflet_heat": ["static/vendor/leaflet-heat/leaflet-heat.js"],
|
||||
}
|
||||
|
||||
|
||||
@@ -63,26 +57,26 @@ def get_offline_settings():
|
||||
return settings
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['GET'])
|
||||
@offline_bp.route("/settings", methods=["GET"])
|
||||
def get_settings():
|
||||
"""Get current offline settings."""
|
||||
settings = get_offline_settings()
|
||||
return api_success(data={'settings': settings})
|
||||
return api_success(data={"settings": settings})
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['POST'])
|
||||
@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 api_error('Missing key or value', 400)
|
||||
if not data or "key" not in data or "value" not in data:
|
||||
return api_error("Missing key or value", 400)
|
||||
|
||||
key = data['key']
|
||||
value = data['value']
|
||||
key = data["key"]
|
||||
value = data["value"]
|
||||
|
||||
# Validate key is an allowed setting
|
||||
if key not in OFFLINE_DEFAULTS:
|
||||
return api_error(f'Unknown setting: {key}', 400)
|
||||
return api_error(f"Unknown setting: {key}", 400)
|
||||
|
||||
# Validate value type matches default
|
||||
default_type = type(OFFLINE_DEFAULTS[key])
|
||||
@@ -90,18 +84,18 @@ def save_setting():
|
||||
# Try to convert
|
||||
try:
|
||||
if default_type == bool:
|
||||
value = str(value).lower() in ('true', '1', 'yes')
|
||||
value = str(value).lower() in ("true", "1", "yes")
|
||||
else:
|
||||
value = default_type(value)
|
||||
except (ValueError, TypeError):
|
||||
return api_error(f'Invalid value type for {key}', 400)
|
||||
return api_error(f"Invalid value type for {key}", 400)
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
return api_success(data={"key": key, "value": value})
|
||||
|
||||
|
||||
@offline_bp.route('/status', methods=['GET'])
|
||||
@offline_bp.route("/status", methods=["GET"])
|
||||
def get_status():
|
||||
"""Check status of local assets."""
|
||||
# Get the app root directory
|
||||
@@ -119,37 +113,36 @@ def get_status():
|
||||
available = False
|
||||
missing.append(path)
|
||||
|
||||
results[asset_name] = {
|
||||
'available': available,
|
||||
'missing': missing if not available else []
|
||||
}
|
||||
results[asset_name] = {"available": available, "missing": missing if not available else []}
|
||||
|
||||
if not available:
|
||||
all_available = False
|
||||
|
||||
return api_success(data={
|
||||
'all_available': all_available,
|
||||
'assets': results,
|
||||
'offline_enabled': get_setting('offline.enabled', False)
|
||||
})
|
||||
return api_success(
|
||||
data={
|
||||
"all_available": all_available,
|
||||
"assets": results,
|
||||
"offline_enabled": get_setting("offline.enabled", False),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@offline_bp.route('/check-asset', methods=['GET'])
|
||||
@offline_bp.route("/check-asset", methods=["GET"])
|
||||
def check_asset():
|
||||
"""Check if a specific asset file exists."""
|
||||
path = request.args.get('path', '')
|
||||
path = request.args.get("path", "")
|
||||
if not path:
|
||||
return api_error('Missing path parameter', 400)
|
||||
return api_error("Missing path parameter", 400)
|
||||
|
||||
# Security: only allow checking within static/vendor
|
||||
if not path.startswith('/static/vendor/'):
|
||||
return api_error('Invalid path', 400)
|
||||
if not path.startswith("/static/vendor/"):
|
||||
return api_error("Invalid path", 400)
|
||||
|
||||
# Remove leading slash and construct full path
|
||||
relative_path = path.lstrip('/')
|
||||
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 api_success(data={'path': path, 'exists': exists})
|
||||
return api_success(data={"path": path, "exists": exists})
|
||||
|
||||
+337
-262
File diff suppressed because it is too large
Load Diff
+235
-231
@@ -16,6 +16,7 @@ from typing import Any
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
import app as app_module
|
||||
from routes.satellite import get_cached_tle
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
@@ -26,13 +27,13 @@ from utils.sstv import (
|
||||
is_sstv_available,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.sstv')
|
||||
logger = get_logger("intercept.sstv")
|
||||
|
||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||
sstv_bp = Blueprint("sstv", __name__, url_prefix="/sstv")
|
||||
|
||||
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
||||
# can type nearby values and still land on the canonical center frequency.
|
||||
ISS_SSTV_MODULATION = 'fm'
|
||||
ISS_SSTV_MODULATION = "fm"
|
||||
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
||||
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||
|
||||
@@ -59,7 +60,7 @@ _timescale_lock = threading.Lock()
|
||||
|
||||
# Track which device is being used
|
||||
sstv_active_device: int | None = None
|
||||
sstv_active_sdr_type: str = 'rtlsdr'
|
||||
sstv_active_sdr_type: str = "rtlsdr"
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
@@ -82,7 +83,7 @@ def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
@sstv_bp.route('/status')
|
||||
@sstv_bp.route("/status")
|
||||
def get_status():
|
||||
"""
|
||||
Get SSTV decoder status.
|
||||
@@ -94,24 +95,24 @@ def get_status():
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
result = {
|
||||
'available': available,
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'iss_frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'image_count': len(decoder.get_images()),
|
||||
'doppler_enabled': decoder.doppler_enabled,
|
||||
"available": available,
|
||||
"decoder": decoder.decoder_available,
|
||||
"running": decoder.is_running,
|
||||
"iss_frequency": ISS_SSTV_FREQ,
|
||||
"modulation": ISS_SSTV_MODULATION,
|
||||
"image_count": len(decoder.get_images()),
|
||||
"doppler_enabled": decoder.doppler_enabled,
|
||||
}
|
||||
|
||||
# Include Doppler info if available
|
||||
doppler_info = decoder.last_doppler_info
|
||||
if doppler_info:
|
||||
result['doppler'] = doppler_info.to_dict()
|
||||
result["doppler"] = doppler_info.to_dict()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sstv_bp.route('/start', methods=['POST'])
|
||||
@sstv_bp.route("/start", methods=["POST"])
|
||||
def start_decoder():
|
||||
"""
|
||||
Start SSTV decoder.
|
||||
@@ -133,20 +134,24 @@ def start_decoder():
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_sstv_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow",
|
||||
}
|
||||
), 400
|
||||
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "already_running",
|
||||
"frequency": ISS_SSTV_FREQ,
|
||||
"modulation": ISS_SSTV_MODULATION,
|
||||
"doppler_enabled": decoder.doppler_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
# Clear queue
|
||||
while not _sstv_queue.empty():
|
||||
@@ -157,43 +162,38 @@ def start_decoder():
|
||||
|
||||
# Get parameters
|
||||
data = request.get_json(silent=True) or {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
sdr_type_str = data.get("sdr_type", "rtlsdr")
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
||||
}), 400
|
||||
if sdr_type_str != "rtlsdr":
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"{sdr_type_str.replace('_', ' ').title()} is not yet supported for this mode. Please use an RTL-SDR device.",
|
||||
}
|
||||
), 400
|
||||
|
||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||
device_index = data.get('device', 0)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
frequency = data.get("frequency", ISS_SSTV_FREQ)
|
||||
modulation = str(data.get("modulation", ISS_SSTV_MODULATION)).strip().lower()
|
||||
device_index = data.get("device", 0)
|
||||
latitude = data.get("latitude")
|
||||
longitude = data.get("longitude")
|
||||
|
||||
# Validate modulation (ISS mode is FM-only)
|
||||
if modulation != ISS_SSTV_MODULATION:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
||||
}), 400
|
||||
return jsonify(
|
||||
{"status": "error", "message": f"Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode"}
|
||||
), 400
|
||||
|
||||
# Validate frequency
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
normalized_frequency = _normalize_iss_frequency(frequency)
|
||||
if normalized_frequency is None:
|
||||
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||
}), 400
|
||||
supported = ", ".join(f"{freq:.3f}" for freq in ISS_SSTV_FREQUENCIES)
|
||||
return jsonify({"status": "error", "message": f"Supported ISS SSTV frequency: {supported} MHz FM"}), 400
|
||||
frequency = normalized_frequency
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid frequency'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Invalid frequency"}), 400
|
||||
|
||||
# Validate location if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
@@ -201,20 +201,11 @@ def start_decoder():
|
||||
latitude = float(latitude)
|
||||
longitude = float(longitude)
|
||||
if not (-90 <= latitude <= 90):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Latitude must be between -90 and 90'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Latitude must be between -90 and 90"}), 400
|
||||
if not (-180 <= longitude <= 180):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Longitude must be between -180 and 180'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Longitude must be between -180 and 180"}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid latitude or longitude'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "Invalid latitude or longitude"}), 400
|
||||
else:
|
||||
latitude = None
|
||||
longitude = None
|
||||
@@ -222,13 +213,9 @@ def start_decoder():
|
||||
# Claim SDR device
|
||||
global sstv_active_device, sstv_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
|
||||
error = app_module.claim_sdr_device(device_int, "sstv", sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
@@ -245,28 +232,25 @@ def start_decoder():
|
||||
sstv_active_sdr_type = sdr_type_str
|
||||
|
||||
result = {
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'device': device_index,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
"status": "started",
|
||||
"frequency": frequency,
|
||||
"modulation": ISS_SSTV_MODULATION,
|
||||
"device": device_index,
|
||||
"doppler_enabled": decoder.doppler_enabled,
|
||||
}
|
||||
|
||||
# Include initial Doppler info if available
|
||||
if decoder.doppler_enabled and decoder.last_doppler_info:
|
||||
result['doppler'] = decoder.last_doppler_info.to_dict()
|
||||
result["doppler"] = decoder.last_doppler_info.to_dict()
|
||||
|
||||
return jsonify(result)
|
||||
else:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder'
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": "Failed to start decoder"}), 500
|
||||
|
||||
|
||||
@sstv_bp.route('/stop', methods=['POST'])
|
||||
@sstv_bp.route("/stop", methods=["POST"])
|
||||
def stop_decoder():
|
||||
"""
|
||||
Stop SSTV decoder.
|
||||
@@ -283,10 +267,10 @@ def stop_decoder():
|
||||
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
|
||||
sstv_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
return jsonify({"status": "stopped"})
|
||||
|
||||
|
||||
@sstv_bp.route('/doppler')
|
||||
@sstv_bp.route("/doppler")
|
||||
def get_doppler():
|
||||
"""
|
||||
Get current Doppler shift information.
|
||||
@@ -299,27 +283,28 @@ def get_doppler():
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
if not decoder.doppler_enabled:
|
||||
return jsonify({
|
||||
'status': 'disabled',
|
||||
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "disabled",
|
||||
"message": "Doppler tracking not enabled. Provide latitude/longitude when starting decoder.",
|
||||
}
|
||||
)
|
||||
|
||||
doppler_info = decoder.last_doppler_info
|
||||
if not doppler_info:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'Doppler data not yet available'
|
||||
})
|
||||
return jsonify({"status": "unavailable", "message": "Doppler data not yet available"})
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'doppler': doppler_info.to_dict(),
|
||||
'nominal_frequency_mhz': ISS_SSTV_FREQ,
|
||||
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"doppler": doppler_info.to_dict(),
|
||||
"nominal_frequency_mhz": ISS_SSTV_FREQ,
|
||||
"corrected_frequency_mhz": doppler_info.frequency_hz / 1_000_000,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@sstv_bp.route('/images')
|
||||
@sstv_bp.route("/images")
|
||||
def list_images():
|
||||
"""
|
||||
Get list of decoded SSTV images.
|
||||
@@ -333,18 +318,14 @@ def list_images():
|
||||
decoder = get_sstv_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
limit = request.args.get('limit', type=int)
|
||||
limit = request.args.get("limit", type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images)
|
||||
})
|
||||
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>')
|
||||
@sstv_bp.route("/images/<filename>")
|
||||
def get_image(filename: str):
|
||||
"""
|
||||
Get a decoded SSTV image file.
|
||||
@@ -358,22 +339,22 @@ def get_image(filename: str):
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
|
||||
return api_error("Invalid filename", 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return api_error('Only PNG files supported', 400)
|
||||
if not filename.endswith(".png"):
|
||||
return api_error("Only PNG files supported", 400)
|
||||
|
||||
# Find image in decoder's output directory
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
return api_error("Image not found", 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
return send_file(image_path, mimetype="image/png")
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>/download')
|
||||
@sstv_bp.route("/images/<filename>/download")
|
||||
def download_image(filename: str):
|
||||
"""
|
||||
Download a decoded SSTV image file.
|
||||
@@ -387,21 +368,21 @@ def download_image(filename: str):
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
|
||||
return api_error("Invalid filename", 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return api_error('Only PNG files supported', 400)
|
||||
if not filename.endswith(".png"):
|
||||
return api_error("Only PNG files supported", 400)
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
return api_error("Image not found", 404)
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
return send_file(image_path, mimetype="image/png", as_attachment=True, download_name=filename)
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
@sstv_bp.route("/images/<filename>", methods=["DELETE"])
|
||||
def delete_image(filename: str):
|
||||
"""
|
||||
Delete a decoded SSTV image.
|
||||
@@ -415,19 +396,19 @@ def delete_image(filename: str):
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return api_error('Invalid filename', 400)
|
||||
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
|
||||
return api_error("Invalid filename", 400)
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return api_error('Only PNG files supported', 400)
|
||||
if not filename.endswith(".png"):
|
||||
return api_error("Only PNG files supported", 400)
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
return jsonify({"status": "ok"})
|
||||
else:
|
||||
return api_error('Image not found', 404)
|
||||
return api_error("Image not found", 404)
|
||||
|
||||
|
||||
@sstv_bp.route('/images', methods=['DELETE'])
|
||||
@sstv_bp.route("/images", methods=["DELETE"])
|
||||
def delete_all_images():
|
||||
"""
|
||||
Delete all decoded SSTV images.
|
||||
@@ -437,10 +418,10 @@ def delete_all_images():
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
return jsonify({"status": "ok", "deleted": count})
|
||||
|
||||
|
||||
@sstv_bp.route('/stream')
|
||||
@sstv_bp.route("/stream")
|
||||
def stream_progress():
|
||||
"""
|
||||
SSE stream of SSTV decode progress.
|
||||
@@ -453,36 +434,38 @@ def stream_progress():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv', msg, msg.get('type'))
|
||||
process_event("sstv", msg, msg.get("type"))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_queue,
|
||||
channel_key='sstv',
|
||||
channel_key="sstv",
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
response.headers["Connection"] = "keep-alive"
|
||||
return response
|
||||
|
||||
|
||||
def _get_timescale():
|
||||
"""Return a cached skyfield timescale (expensive to create)."""
|
||||
global _timescale
|
||||
with _timescale_lock:
|
||||
if _timescale is None:
|
||||
from skyfield.api import load
|
||||
_timescale = load.timescale(builtin=True)
|
||||
return _timescale
|
||||
def _get_timescale():
|
||||
"""Return a cached skyfield timescale (expensive to create)."""
|
||||
global _timescale
|
||||
with _timescale_lock:
|
||||
if _timescale is None:
|
||||
from skyfield.api import load
|
||||
|
||||
_timescale = load.timescale(builtin=True)
|
||||
return _timescale
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-schedule')
|
||||
@sstv_bp.route("/iss-schedule")
|
||||
def iss_schedule():
|
||||
"""
|
||||
Get ISS pass schedule for SSTV reception.
|
||||
@@ -500,24 +483,23 @@ def iss_schedule():
|
||||
"""
|
||||
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
|
||||
|
||||
lat = request.args.get('latitude', type=float)
|
||||
lon = request.args.get('longitude', type=float)
|
||||
hours = request.args.get('hours', 48, type=int)
|
||||
lat = request.args.get("latitude", type=float)
|
||||
lon = request.args.get("longitude", type=float)
|
||||
hours = request.args.get("hours", 48, type=int)
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "latitude and longitude parameters required"}), 400
|
||||
|
||||
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
|
||||
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
|
||||
|
||||
with _iss_schedule_lock:
|
||||
now = time.time()
|
||||
if (_iss_schedule_cache is not None
|
||||
and cache_key == _iss_schedule_cache_key
|
||||
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
|
||||
if (
|
||||
_iss_schedule_cache is not None
|
||||
and cache_key == _iss_schedule_cache_key
|
||||
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL
|
||||
):
|
||||
return jsonify(_iss_schedule_cache)
|
||||
|
||||
try:
|
||||
@@ -526,15 +508,10 @@ def iss_schedule():
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
# Get ISS TLE
|
||||
iss_tle = TLE_SATELLITES.get('ISS')
|
||||
# Get ISS TLE from live cache (kept fresh by auto-refresh)
|
||||
iss_tle = get_cached_tle("ISS")
|
||||
if not iss_tle:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ISS TLE data not available'
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": "ISS TLE data not available"}), 500
|
||||
|
||||
ts = _get_timescale()
|
||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||
@@ -549,7 +526,7 @@ def iss_schedule():
|
||||
alt, _, _ = topocentric.altaz()
|
||||
return alt.degrees > 0
|
||||
|
||||
above_horizon.step_days = 1/720
|
||||
above_horizon.step_days = 1 / 720
|
||||
|
||||
times, events = find_discrete(t0, t1, above_horizon)
|
||||
|
||||
@@ -588,23 +565,25 @@ def iss_schedule():
|
||||
max_el = alt.degrees
|
||||
|
||||
if max_el >= 10: # Min elevation filter
|
||||
passes.append({
|
||||
'satellite': 'ISS',
|
||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||
'maxEl': round(max_el, 1),
|
||||
'duration': duration_minutes,
|
||||
'color': '#00ffff'
|
||||
})
|
||||
passes.append(
|
||||
{
|
||||
"satellite": "ISS",
|
||||
"startTime": rise_time.utc_datetime().strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"startTimeISO": rise_time.utc_datetime().isoformat(),
|
||||
"maxEl": round(max_el, 1),
|
||||
"duration": duration_minutes,
|
||||
"color": "#00ffff",
|
||||
}
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'passes': passes,
|
||||
'count': len(passes),
|
||||
'sstv_frequency': ISS_SSTV_FREQ,
|
||||
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
||||
"status": "ok",
|
||||
"passes": passes,
|
||||
"count": len(passes),
|
||||
"sstv_frequency": ISS_SSTV_FREQ,
|
||||
"note": "ISS SSTV events are not continuous. Check ARISS.org for scheduled events.",
|
||||
}
|
||||
|
||||
# Update cache
|
||||
@@ -616,17 +595,11 @@ def iss_schedule():
|
||||
return jsonify(result)
|
||||
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed'
|
||||
}), 503
|
||||
return jsonify({"status": "error", "message": "skyfield library not installed"}), 503
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ISS schedule: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
|
||||
def _fetch_iss_position() -> dict | None:
|
||||
@@ -644,14 +617,14 @@ def _fetch_iss_position() -> dict | None:
|
||||
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
|
||||
response = requests.get("https://api.wheretheiss.at/v1/satellites/25544", timeout=3)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
cached = {
|
||||
'lat': float(data['latitude']),
|
||||
'lon': float(data['longitude']),
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'source': 'wheretheiss',
|
||||
"lat": float(data["latitude"]),
|
||||
"lon": float(data["longitude"]),
|
||||
"altitude": float(data.get("altitude", 420)),
|
||||
"source": "wheretheiss",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
@@ -659,15 +632,15 @@ def _fetch_iss_position() -> dict | None:
|
||||
# Try fallback API: Open Notify
|
||||
if cached is None:
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
|
||||
response = requests.get("http://api.open-notify.org/iss-now.json", timeout=3)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('message') == 'success':
|
||||
if data.get("message") == "success":
|
||||
cached = {
|
||||
'lat': float(data['iss_position']['latitude']),
|
||||
'lon': float(data['iss_position']['longitude']),
|
||||
'altitude': 420,
|
||||
'source': 'open-notify',
|
||||
"lat": float(data["iss_position"]["latitude"]),
|
||||
"lon": float(data["iss_position"]["longitude"]),
|
||||
"altitude": 420,
|
||||
"source": "open-notify",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Open Notify API failed: {e}")
|
||||
@@ -680,7 +653,7 @@ def _fetch_iss_position() -> dict | None:
|
||||
return cached
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-position')
|
||||
@sstv_bp.route("/iss-position")
|
||||
def iss_position():
|
||||
"""
|
||||
Get current ISS position from real-time API.
|
||||
@@ -698,28 +671,25 @@ def iss_position():
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
observer_lat = request.args.get("latitude", type=float)
|
||||
observer_lon = request.args.get("longitude", type=float)
|
||||
|
||||
pos = _fetch_iss_position()
|
||||
if pos is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||
}), 503
|
||||
return jsonify({"status": "error", "message": "Unable to fetch ISS position from real-time APIs"}), 503
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': pos['lat'],
|
||||
'lon': pos['lon'],
|
||||
'altitude': pos['altitude'],
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': pos['source'],
|
||||
"status": "ok",
|
||||
"lat": pos["lat"],
|
||||
"lon": pos["lon"],
|
||||
"altitude": pos["altitude"],
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"source": pos["source"],
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
|
||||
result.update(_calculate_observer_data(pos["lat"], pos["lon"], observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@@ -743,7 +713,7 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
|
||||
# Haversine for ground distance
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
ground_distance = earth_radius * c
|
||||
|
||||
@@ -763,14 +733,60 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
|
||||
azimuth = math.degrees(math.atan2(y, x))
|
||||
azimuth = (azimuth + 360) % 360
|
||||
|
||||
return {
|
||||
'elevation': round(elevation, 1),
|
||||
'azimuth': round(azimuth, 1),
|
||||
'distance': round(slant_range, 1)
|
||||
}
|
||||
return {"elevation": round(elevation, 1), "azimuth": round(azimuth, 1), "distance": round(slant_range, 1)}
|
||||
|
||||
|
||||
@sstv_bp.route('/decode-file', methods=['POST'])
|
||||
@sstv_bp.route("/iss-track")
|
||||
def iss_track():
|
||||
"""
|
||||
Return ISS ground track points propagated from TLE data.
|
||||
|
||||
Uses skyfield SGP4 propagation over ±90 minutes (roughly one full orbit)
|
||||
to produce an accurate track that accounts for Earth's rotation.
|
||||
|
||||
Returns:
|
||||
JSON with list of {lat, lon, past} points.
|
||||
"""
|
||||
try:
|
||||
from datetime import timedelta
|
||||
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
|
||||
iss_tle = get_cached_tle("ISS")
|
||||
if not iss_tle:
|
||||
return jsonify({"status": "error", "message": "ISS TLE not available"}), 500
|
||||
|
||||
ts = _get_timescale()
|
||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
|
||||
track = []
|
||||
for minutes_offset in range(-90, 91, 1):
|
||||
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
|
||||
try:
|
||||
geo = satellite.at(t_point)
|
||||
sp = wgs84.subpoint(geo)
|
||||
track.append(
|
||||
{
|
||||
"lat": round(float(sp.latitude.degrees), 4),
|
||||
"lon": round(float(sp.longitude.degrees), 4),
|
||||
"past": minutes_offset < 0,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return jsonify({"status": "ok", "track": track})
|
||||
|
||||
except ImportError:
|
||||
return jsonify({"status": "error", "message": "skyfield not installed"}), 503
|
||||
except Exception as e:
|
||||
logger.error(f"Error computing ISS track: {e}")
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
|
||||
@sstv_bp.route("/decode-file", methods=["POST"])
|
||||
def decode_file():
|
||||
"""
|
||||
Decode SSTV from an uploaded audio file.
|
||||
@@ -780,23 +796,18 @@ def decode_file():
|
||||
Returns:
|
||||
JSON with decoded images.
|
||||
"""
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No audio file provided'
|
||||
}), 400
|
||||
if "audio" not in request.files:
|
||||
return jsonify({"status": "error", "message": "No audio file provided"}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
audio_file = request.files["audio"]
|
||||
|
||||
if not audio_file.filename:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No file selected'
|
||||
}), 400
|
||||
return jsonify({"status": "error", "message": "No file selected"}), 400
|
||||
|
||||
# Save to temp file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
audio_file.save(tmp.name)
|
||||
tmp_path = tmp.name
|
||||
|
||||
@@ -804,18 +815,11 @@ def decode_file():
|
||||
decoder = get_sstv_decoder()
|
||||
images = decoder.decode_file(tmp_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images)
|
||||
})
|
||||
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding file: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
|
||||
@@ -490,6 +490,7 @@ def _start_sweep_internal(
|
||||
bt_interface: str = '',
|
||||
sdr_device: int | None = None,
|
||||
verbose_results: bool = False,
|
||||
custom_ranges: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Start a TSCM sweep without request context."""
|
||||
global _sweep_running, _sweep_thread, _current_sweep_id
|
||||
@@ -532,7 +533,7 @@ def _start_sweep_internal(
|
||||
_sweep_thread = threading.Thread(
|
||||
target=_run_sweep,
|
||||
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
|
||||
wifi_interface, bt_interface, sdr_device, verbose_results),
|
||||
wifi_interface, bt_interface, sdr_device, verbose_results, custom_ranges),
|
||||
daemon=True
|
||||
)
|
||||
_sweep_thread.start()
|
||||
@@ -1127,7 +1128,8 @@ def _run_sweep(
|
||||
wifi_interface: str = '',
|
||||
bt_interface: str = '',
|
||||
sdr_device: int | None = None,
|
||||
verbose_results: bool = False
|
||||
verbose_results: bool = False,
|
||||
custom_ranges: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the TSCM sweep in a background thread.
|
||||
@@ -1504,7 +1506,7 @@ def _run_sweep(
|
||||
'rf_count': len(all_rf),
|
||||
})
|
||||
# Try RF scan even if sdr_device is None (will use device 0)
|
||||
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=preset.get('ranges'))
|
||||
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=custom_ranges or preset.get('ranges'))
|
||||
|
||||
# If no signals and this is first RF scan, send info event
|
||||
if not rf_signals and last_rf_scan == 0:
|
||||
|
||||
+20
-3
@@ -19,12 +19,9 @@ from flask import Response, jsonify, request
|
||||
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
|
||||
from routes.tscm import (
|
||||
_baseline_recorder,
|
||||
_current_sweep_id,
|
||||
_emit_event,
|
||||
_start_sweep_internal,
|
||||
_sweep_running,
|
||||
tscm_bp,
|
||||
tscm_queue,
|
||||
)
|
||||
from utils.database import get_tscm_sweep, update_tscm_sweep
|
||||
from utils.event_pipeline import process_event
|
||||
@@ -58,6 +55,25 @@ def start_sweep():
|
||||
bt_interface = data.get('bt_interface', '')
|
||||
sdr_device = data.get('sdr_device')
|
||||
|
||||
# Validate custom frequency ranges if provided
|
||||
custom_ranges = None
|
||||
if sweep_type == 'custom':
|
||||
raw_ranges = data.get('custom_ranges') or []
|
||||
validated = []
|
||||
for rng in raw_ranges:
|
||||
try:
|
||||
start = float(rng.get('start', 0))
|
||||
end = float(rng.get('end', 0))
|
||||
step = float(rng.get('step', 0.1))
|
||||
if 0 < start < end <= 6000:
|
||||
validated.append({'start': start, 'end': end, 'step': step,
|
||||
'name': rng.get('name') or f'{start:.0f}–{end:.0f} MHz'})
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not validated:
|
||||
return jsonify({'status': 'error', 'message': 'custom sweep requires valid start/end MHz'}), 400
|
||||
custom_ranges = validated
|
||||
|
||||
result = _start_sweep_internal(
|
||||
sweep_type=sweep_type,
|
||||
baseline_id=baseline_id,
|
||||
@@ -68,6 +84,7 @@ def start_sweep():
|
||||
bt_interface=bt_interface,
|
||||
sdr_device=sdr_device,
|
||||
verbose_results=verbose_results,
|
||||
custom_ranges=custom_ranges,
|
||||
)
|
||||
http_status = result.pop('http_status', 200)
|
||||
return jsonify(result), http_status
|
||||
|
||||
+165
-165
@@ -1,17 +1,17 @@
|
||||
"""Weather Satellite decoder routes.
|
||||
|
||||
Provides endpoints for capturing and decoding Meteor LRPT weather
|
||||
imagery, including shared results produced by the ground-station
|
||||
observation pipeline.
|
||||
"""
|
||||
"""Weather Satellite decoder routes.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
Provides endpoints for capturing and decoding Meteor LRPT weather
|
||||
imagery, including shared results produced by the ground-station
|
||||
observation pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error
|
||||
@@ -33,21 +33,21 @@ from utils.weather_sat import (
|
||||
is_weather_sat_available,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
|
||||
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
|
||||
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
METEOR_NORAD_IDS = {
|
||||
'METEOR-M2-3': 57166,
|
||||
'METEOR-M2-4': 59051,
|
||||
}
|
||||
ALLOWED_TEST_DECODE_DIRS = (
|
||||
Path(__file__).resolve().parent.parent / 'data',
|
||||
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
|
||||
)
|
||||
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
METEOR_NORAD_IDS = {
|
||||
'METEOR-M2-3': 57166,
|
||||
'METEOR-M2-4': 59051,
|
||||
}
|
||||
ALLOWED_TEST_DECODE_DIRS = (
|
||||
Path(__file__).resolve().parent.parent / 'data',
|
||||
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
|
||||
)
|
||||
|
||||
|
||||
def _progress_callback(progress: CaptureProgress) -> None:
|
||||
@@ -132,9 +132,9 @@ def start_capture():
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "METEOR-M2-3", // Required: satellite key
|
||||
"satellite": "METEOR-M2-3", // Required: satellite key
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||
"gain": 30.0, // SDR gain in dB (default: 30)
|
||||
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ def start_capture():
|
||||
# Validate device index and gain
|
||||
try:
|
||||
device_index = validate_device_index(data.get('device', 0))
|
||||
gain = validate_gain(data.get('gain', 40.0))
|
||||
gain = validate_gain(data.get('gain', 30.0))
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid parameter in start_capture: %s', e)
|
||||
return jsonify({
|
||||
@@ -260,7 +260,7 @@ def test_decode():
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "METEOR-M2-3", // Required: satellite key
|
||||
"satellite": "METEOR-M2-3", // Required: satellite key
|
||||
"input_file": "/path/to/file", // Required: server-side file path
|
||||
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
||||
}
|
||||
@@ -304,14 +304,14 @@ def test_decode():
|
||||
from pathlib import Path
|
||||
input_path = Path(input_file)
|
||||
|
||||
# Restrict test-decode to application-owned sample and recording paths.
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
|
||||
}), 403
|
||||
# Restrict test-decode to application-owned sample and recording paths.
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
|
||||
}), 403
|
||||
except (OSError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -388,8 +388,8 @@ def stop_capture():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images')
|
||||
def list_images():
|
||||
@weather_sat_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded weather satellite images.
|
||||
|
||||
Query parameters:
|
||||
@@ -399,41 +399,41 @@ def list_images():
|
||||
Returns:
|
||||
JSON with list of decoded images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
images = [
|
||||
{
|
||||
**img.to_dict(),
|
||||
'source': 'weather_sat',
|
||||
'deletable': True,
|
||||
}
|
||||
for img in decoder.get_images()
|
||||
]
|
||||
images.extend(_get_ground_station_images())
|
||||
|
||||
# Filter by satellite if specified
|
||||
satellite_filter = request.args.get('satellite')
|
||||
if satellite_filter:
|
||||
images = [
|
||||
img for img in images
|
||||
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
|
||||
]
|
||||
|
||||
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[:limit]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': images,
|
||||
'count': len(images),
|
||||
})
|
||||
decoder = get_weather_sat_decoder()
|
||||
images = [
|
||||
{
|
||||
**img.to_dict(),
|
||||
'source': 'weather_sat',
|
||||
'deletable': True,
|
||||
}
|
||||
for img in decoder.get_images()
|
||||
]
|
||||
images.extend(_get_ground_station_images())
|
||||
|
||||
# Filter by satellite if specified
|
||||
satellite_filter = request.args.get('satellite')
|
||||
if satellite_filter:
|
||||
images = [
|
||||
img for img in images
|
||||
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
|
||||
]
|
||||
|
||||
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[:limit]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': images,
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
@weather_sat_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Serve a decoded weather satellite image file.
|
||||
|
||||
Args:
|
||||
@@ -456,38 +456,38 @@ def get_image(filename: str):
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/shared/<int:output_id>')
|
||||
def get_shared_image(output_id: int):
|
||||
"""Serve a Meteor image stored in ground-station outputs."""
|
||||
try:
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'''
|
||||
SELECT file_path FROM ground_station_outputs
|
||||
WHERE id=? AND output_type='image'
|
||||
''',
|
||||
(output_id,),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load shared weather image %s: %s", output_id, e)
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
if not row:
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
image_path = Path(row['file_path'])
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
suffix = image_path.suffix.lower()
|
||||
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/shared/<int:output_id>')
|
||||
def get_shared_image(output_id: int):
|
||||
"""Serve a Meteor image stored in ground-station outputs."""
|
||||
try:
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'''
|
||||
SELECT file_path FROM ground_station_outputs
|
||||
WHERE id=? AND output_type='image'
|
||||
''',
|
||||
(output_id,),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load shared weather image %s: %s", output_id, e)
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
if not row:
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
image_path = Path(row['file_path'])
|
||||
if not image_path.exists():
|
||||
return api_error('Image not found', 404)
|
||||
|
||||
suffix = image_path.suffix.lower()
|
||||
mimetype = 'image/png' if suffix == '.png' else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
@@ -512,71 +512,71 @@ def delete_image(filename: str):
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
def delete_all_images():
|
||||
"""Delete all decoded weather satellite images.
|
||||
|
||||
Returns:
|
||||
JSON with count of deleted images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
def _get_ground_station_images() -> list[dict]:
|
||||
try:
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'''
|
||||
SELECT id, norad_id, file_path, metadata_json, created_at
|
||||
FROM ground_station_outputs
|
||||
WHERE output_type='image' AND backend='meteor_lrpt'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
'''
|
||||
).fetchall()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
|
||||
return []
|
||||
|
||||
images: list[dict] = []
|
||||
for row in rows:
|
||||
file_path = Path(row['file_path'])
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
metadata = {}
|
||||
raw_metadata = row['metadata_json']
|
||||
if raw_metadata:
|
||||
try:
|
||||
metadata = json.loads(raw_metadata)
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
|
||||
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
|
||||
images.append({
|
||||
'filename': file_path.name,
|
||||
'satellite': satellite,
|
||||
'mode': metadata.get('mode', 'LRPT'),
|
||||
'timestamp': metadata.get('timestamp') or row['created_at'],
|
||||
'frequency': metadata.get('frequency', 137.9),
|
||||
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
|
||||
'product': metadata.get('product', ''),
|
||||
'url': f"/weather-sat/images/shared/{row['id']}",
|
||||
'source': 'ground_station',
|
||||
'deletable': False,
|
||||
'output_id': row['id'],
|
||||
})
|
||||
return images
|
||||
|
||||
|
||||
def _satellite_from_norad(norad_id: int | None) -> str:
|
||||
for satellite, known_norad in METEOR_NORAD_IDS.items():
|
||||
if known_norad == norad_id:
|
||||
return satellite
|
||||
return 'METEOR'
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
def _get_ground_station_images() -> list[dict]:
|
||||
try:
|
||||
from utils.database import get_db
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'''
|
||||
SELECT id, norad_id, file_path, metadata_json, created_at
|
||||
FROM ground_station_outputs
|
||||
WHERE output_type='image' AND backend='meteor_lrpt'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
'''
|
||||
).fetchall()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
|
||||
return []
|
||||
|
||||
images: list[dict] = []
|
||||
for row in rows:
|
||||
file_path = Path(row['file_path'])
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
metadata = {}
|
||||
raw_metadata = row['metadata_json']
|
||||
if raw_metadata:
|
||||
try:
|
||||
metadata = json.loads(raw_metadata)
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
|
||||
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
|
||||
images.append({
|
||||
'filename': file_path.name,
|
||||
'satellite': satellite,
|
||||
'mode': metadata.get('mode', 'LRPT'),
|
||||
'timestamp': metadata.get('timestamp') or row['created_at'],
|
||||
'frequency': metadata.get('frequency', 137.9),
|
||||
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
|
||||
'product': metadata.get('product', ''),
|
||||
'url': f"/weather-sat/images/shared/{row['id']}",
|
||||
'source': 'ground_station',
|
||||
'deletable': False,
|
||||
'output_id': row['id'],
|
||||
})
|
||||
return images
|
||||
|
||||
|
||||
def _satellite_from_norad(norad_id: int | None) -> str:
|
||||
for satellite, known_norad in METEOR_NORAD_IDS.items():
|
||||
if known_norad == norad_id:
|
||||
return satellite
|
||||
return 'METEOR'
|
||||
|
||||
|
||||
@weather_sat_bp.route('/stream')
|
||||
@@ -689,7 +689,7 @@ def enable_schedule():
|
||||
"longitude": -0.1, // Required
|
||||
"min_elevation": 15, // Minimum pass elevation (default: 15)
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain (default: 40)
|
||||
"gain": 30.0, // SDR gain (default: 30)
|
||||
"bias_t": false // Enable bias-T (default: false)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@@ -178,7 +177,6 @@
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
@supports (clip-path: polygon(0 0)) {
|
||||
@@ -233,9 +231,25 @@
|
||||
background: var(--status-offline);
|
||||
}
|
||||
|
||||
@keyframes panel-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.panel-indicator.active {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 8px var(--status-online);
|
||||
animation: panel-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel-indicator.active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-animations="off"] .panel-indicator.active {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
@@ -1152,3 +1166,18 @@ textarea:focus {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
/* Visuals Container Base Styles */
|
||||
.visuals-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.visuals-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--scanline);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
+29
-17
@@ -741,16 +741,17 @@
|
||||
}
|
||||
|
||||
.mode-nav-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.mode-nav-btn.active {
|
||||
background: var(--bg-elevated);
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2);
|
||||
padding-left: 12px; /* compensate for 2px border */
|
||||
}
|
||||
|
||||
.mode-nav-btn.active .nav-icon {
|
||||
@@ -838,7 +839,7 @@
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
@@ -854,10 +855,11 @@
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: var(--bg-elevated);
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 8px rgba(74, 163, 255, 0.2);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||
@@ -901,9 +903,11 @@
|
||||
}
|
||||
|
||||
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: var(--bg-elevated);
|
||||
background: var(--accent-cyan-glow);
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 6px rgba(74, 163, 255, 0.15);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* Focus-visible states for nav elements */
|
||||
@@ -1103,15 +1107,22 @@ a.nav-dashboard-btn:hover {
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
background: rgba(31, 95, 168, 0.08);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.15);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-btn:hover,
|
||||
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn,
|
||||
[data-theme="light"] .mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||
background: rgba(31, 95, 168, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||
background: rgba(220, 230, 244, 0.9);
|
||||
color: var(--text-primary);
|
||||
background: rgba(31, 95, 168, 0.06);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
box-shadow: -2px 0 6px rgba(31, 95, 168, 0.12);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu {
|
||||
@@ -1124,8 +1135,9 @@ a.nav-dashboard-btn:hover {
|
||||
}
|
||||
|
||||
[data-theme="light"] .mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||
background: rgba(220, 230, 244, 0.95);
|
||||
color: var(--text-primary);
|
||||
background: rgba(31, 95, 168, 0.08);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/* ============================================================
|
||||
MAP UTILS — Tactical overlay styles
|
||||
Used by all map-using pages via map-utils.js
|
||||
============================================================ */
|
||||
|
||||
/* --- HUD panel base ---
|
||||
Absolutely positioned dark-glass panels over the Leaflet map container.
|
||||
The map container already has position:relative set by Leaflet. */
|
||||
|
||||
.map-hud-panel {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
padding: 6px 10px;
|
||||
background: rgba(7, 9, 14, 0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(74, 163, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #8ba0b8);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Top-left: mode name + contact count */
|
||||
.map-hud-tl {
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.map-hud-mode {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim, #5a7080);
|
||||
}
|
||||
|
||||
.map-hud-count {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Top-right: UTC clock + status dot */
|
||||
.map-hud-tr {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-hud-clock {
|
||||
color: var(--text-secondary, #8ba0b8);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.map-hud-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim, #5a7080);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-hud-dot.online {
|
||||
background: var(--status-online, #38c180);
|
||||
box-shadow: 0 0 4px var(--status-online, #38c180);
|
||||
}
|
||||
|
||||
.map-hud-dot.offline {
|
||||
background: var(--status-error, #e85d5d);
|
||||
}
|
||||
|
||||
/* --- Observer reticle ---
|
||||
Rendered as a Leaflet divIcon; no extra CSS needed beyond pointer-events. */
|
||||
|
||||
.map-reticle {
|
||||
pointer-events: none !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* --- Range ring labels --- */
|
||||
|
||||
.map-range-label {
|
||||
pointer-events: none !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.map-range-label span {
|
||||
display: inline-block;
|
||||
background: rgba(7, 9, 14, 0.7);
|
||||
color: rgba(74, 163, 255, 0.7);
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Dark glass popup ---
|
||||
Applied via MapUtils.glassPopupOptions() className. */
|
||||
|
||||
.map-glass-popup .leaflet-popup-content-wrapper {
|
||||
background: var(--bg-elevated, #161d28) !important;
|
||||
border: 1px solid var(--border-color, rgba(74,163,255,0.15)) !important;
|
||||
border-radius: 6px !important;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.map-glass-popup .leaflet-popup-content {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-primary, #c8d8e8);
|
||||
}
|
||||
|
||||
.map-glass-popup .leaflet-popup-tip-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-glass-popup .leaflet-popup-close-button {
|
||||
color: var(--text-dim, #5a7080);
|
||||
font-size: 16px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.map-glass-popup .leaflet-popup-close-button:hover {
|
||||
color: var(--text-primary, #c8d8e8);
|
||||
}
|
||||
@@ -10,11 +10,11 @@
|
||||
============================================ */
|
||||
|
||||
/* Backgrounds - layered depth system */
|
||||
--bg-primary: #0b1118;
|
||||
--bg-secondary: #101823;
|
||||
--bg-tertiary: #151f2b;
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
--bg-primary: #07090e;
|
||||
--bg-secondary: #0b1018;
|
||||
--bg-tertiary: #101520;
|
||||
--bg-card: #0d1219;
|
||||
--bg-elevated: #161d28;
|
||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||
--surface-glass: rgba(16, 25, 37, 0.82);
|
||||
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||
@@ -30,6 +30,7 @@
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-cyan-dim: rgba(74, 163, 255, 0.16);
|
||||
--accent-cyan-hover: #6bb3ff;
|
||||
--accent-cyan-glow: rgba(74, 163, 255, 0.12);
|
||||
--accent-green: #38c180;
|
||||
--accent-green-hover: #16a34a;
|
||||
--accent-green-dim: rgba(56, 193, 128, 0.18);
|
||||
@@ -80,6 +81,15 @@
|
||||
--grid-dot: rgba(255, 255, 255, 0.03);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
|
||||
/* Scanline overlay texture */
|
||||
--scanline: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.04) 2px,
|
||||
rgba(0, 0, 0, 0.04) 4px
|
||||
);
|
||||
|
||||
/* ============================================
|
||||
SPACING SCALE
|
||||
============================================ */
|
||||
@@ -236,6 +246,9 @@
|
||||
--grid-dot: rgba(12, 18, 24, 0.06);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
|
||||
--accent-cyan-glow: rgba(31, 95, 168, 0.08);
|
||||
--scanline: none;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
|
||||
|
||||
+531
-359
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -73,6 +73,9 @@ const ProximityRadar = (function() {
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<clipPath id="radarClip">
|
||||
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Background gradient -->
|
||||
@@ -94,10 +97,15 @@ const ProximityRadar = (function() {
|
||||
}).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" />
|
||||
<!-- CSS-animated sweep group: trailing arcs + sweep line -->
|
||||
<g class="bt-radar-sweep" clip-path="url(#radarClip)">
|
||||
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${center + (center - CONFIG.padding)},${center} Z"
|
||||
fill="#00b4d8" opacity="0.035"/>
|
||||
<path d="M${center},${center} L${center},${CONFIG.padding} A${center - CONFIG.padding},${center - CONFIG.padding} 0 0,1 ${Math.round(center + (center - CONFIG.padding) * Math.sin(Math.PI / 3))},${Math.round(center + (center - CONFIG.padding) * (1 - Math.cos(Math.PI / 3)))} Z"
|
||||
fill="#00b4d8" opacity="0.07"/>
|
||||
<line x1="${center}" y1="${center}" x2="${center}" y2="${CONFIG.padding}"
|
||||
stroke="#00b4d8" stroke-width="1.5" opacity="0.75"/>
|
||||
</g>
|
||||
|
||||
<!-- Center point -->
|
||||
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
|
||||
@@ -129,39 +137,6 @@ const ProximityRadar = (function() {
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,6 +468,8 @@ const ProximityRadar = (function() {
|
||||
*/
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
const sweep = svg?.querySelector('.bt-radar-sweep');
|
||||
if (sweep) sweep.style.animationPlayState = paused ? 'paused' : 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,8 @@ const Settings = {
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
'offline.tile_server_url': '',
|
||||
'offline.stadia_key': '',
|
||||
},
|
||||
|
||||
// Tile provider configurations
|
||||
@@ -42,7 +43,19 @@ const Settings = {
|
||||
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
|
||||
}
|
||||
},
|
||||
stadia_dark: {
|
||||
url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
subdomains: null,
|
||||
requiresKey: true,
|
||||
},
|
||||
tactical: {
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_background/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a>',
|
||||
subdomains: null,
|
||||
requiresKey: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Registry of maps that can be updated
|
||||
@@ -213,8 +226,12 @@ const Settings = {
|
||||
async _save(key, value) {
|
||||
this._cache[key] = value;
|
||||
|
||||
// Save to localStorage as backup
|
||||
localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
|
||||
// Save to localStorage as backup (exclude sensitive keys)
|
||||
const SENSITIVE_KEYS = ['offline.stadia_key'];
|
||||
const toStore = Object.fromEntries(
|
||||
Object.entries(this._cache).filter(([k]) => !SENSITIVE_KEYS.includes(k))
|
||||
);
|
||||
localStorage.setItem('intercept_settings', JSON.stringify(toStore));
|
||||
|
||||
// Save to server
|
||||
try {
|
||||
@@ -292,6 +309,13 @@ const Settings = {
|
||||
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Show/hide Stadia API key row
|
||||
const stadiaKeyRow = document.getElementById('stadiaKeyRow');
|
||||
if (stadiaKeyRow) {
|
||||
stadiaKeyRow.style.display =
|
||||
(provider === 'stadia_dark' || provider === 'tactical') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update tiles immediately for all providers.
|
||||
this._updateMapTiles();
|
||||
const activeConfig = this.getTileConfig();
|
||||
@@ -307,6 +331,15 @@ const Settings = {
|
||||
this._updateMapTiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* Save Stadia Maps API key and refresh tiles.
|
||||
* @param {string} key
|
||||
*/
|
||||
async setStadiaKey(key) {
|
||||
await this._save('offline.stadia_key', (key || '').trim());
|
||||
this._updateMapTiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current tile configuration
|
||||
*/
|
||||
@@ -322,15 +355,26 @@ const Settings = {
|
||||
};
|
||||
}
|
||||
|
||||
const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
||||
const baseConfig = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
||||
|
||||
// Robust fallback: if dark Carto is active and Cyber is preferred,
|
||||
// keep Cyber theme enabled even when provider temporarily reverts.
|
||||
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||
return { ...config, mapTheme: 'cyber' };
|
||||
if (baseConfig.requiresKey) {
|
||||
const key = (this.get('offline.stadia_key') || '').trim();
|
||||
if (!key) {
|
||||
// No key — fall back to CartoDB dark so the map isn't broken
|
||||
return this.tileProviders.cartodb_dark;
|
||||
}
|
||||
return {
|
||||
...baseConfig,
|
||||
url: baseConfig.url + '?api_key=' + encodeURIComponent(key),
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
// Robust fallback: keep Cyber theme when CartoDB dark is active and Cyber preferred.
|
||||
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||
return { ...baseConfig, mapTheme: 'cyber' };
|
||||
}
|
||||
|
||||
return baseConfig;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -643,6 +687,18 @@ const Settings = {
|
||||
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Stadia key input
|
||||
const stadiaKeyInput = document.getElementById('stadiaKeyInput');
|
||||
if (stadiaKeyInput) {
|
||||
stadiaKeyInput.value = this.get('offline.stadia_key') || '';
|
||||
}
|
||||
const stadiaKeyRow = document.getElementById('stadiaKeyRow');
|
||||
if (stadiaKeyRow) {
|
||||
const currentProvider = this.get('offline.tile_provider');
|
||||
stadiaKeyRow.style.display =
|
||||
(currentProvider === 'stadia_dark' || currentProvider === 'tactical') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Theme select
|
||||
const themeSelect = document.getElementById('themeSelect');
|
||||
if (themeSelect) {
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* MapUtils — shared Leaflet map initialisation and tactical overlays.
|
||||
*
|
||||
* Usage:
|
||||
* const map = MapUtils.init('myMapDiv', { center: [51.5, -0.1], zoom: 8 });
|
||||
* const overlays = MapUtils.addTacticalOverlays(map, {
|
||||
* rangeRings: { center: [51.5, -0.1], intervals: [50, 100, 150, 200] },
|
||||
* observerReticle: { latlng: [51.5, -0.1] },
|
||||
* hudPanels: { modeName: 'ADS-B', getContactCount: () => 0 },
|
||||
* scaleBar: true,
|
||||
* });
|
||||
* overlays.updateCount(42);
|
||||
*/
|
||||
const MapUtils = {
|
||||
|
||||
/**
|
||||
* Initialise a Leaflet map with Settings-managed tile layer.
|
||||
* Adds a canvas fallback grid immediately, then upgrades to the
|
||||
* configured tile provider asynchronously without blocking.
|
||||
*
|
||||
* @param {string} containerId - DOM element id
|
||||
* @param {Object} [options]
|
||||
* @param {number[]} [options.center=[20,0]]
|
||||
* @param {number} [options.zoom=4]
|
||||
* @param {number} [options.minZoom=2]
|
||||
* @param {number} [options.maxZoom=18]
|
||||
* @param {boolean} [options.zoomControl=true]
|
||||
* @param {boolean} [options.attributionControl=true]
|
||||
* @returns {L.Map|null}
|
||||
*/
|
||||
init(containerId, options = {}) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
// Guard against double init (e.g. back/forward cache restore)
|
||||
if (container._leaflet_id) return null;
|
||||
|
||||
const map = L.map(containerId, {
|
||||
center: options.center || [20, 0],
|
||||
zoom: options.zoom ?? 4,
|
||||
minZoom: options.minZoom ?? 2,
|
||||
maxZoom: options.maxZoom ?? 18,
|
||||
zoomControl: options.zoomControl !== false,
|
||||
attributionControl: options.attributionControl !== false,
|
||||
});
|
||||
|
||||
const fallback = this.createFallbackGridLayer().addTo(map);
|
||||
this._upgradeTiles(map, fallback);
|
||||
|
||||
return map;
|
||||
},
|
||||
|
||||
/**
|
||||
* Async: replace the fallback canvas grid with the Settings tile layer.
|
||||
* @private
|
||||
*/
|
||||
async _upgradeTiles(map, fallback) {
|
||||
if (typeof Settings === 'undefined') return;
|
||||
try {
|
||||
await Settings.init();
|
||||
if (!map || map._removed) return;
|
||||
const layer = Settings.createTileLayer();
|
||||
let loaded = false;
|
||||
layer.once('load', () => {
|
||||
loaded = true;
|
||||
if (map.hasLayer(fallback)) map.removeLayer(fallback);
|
||||
});
|
||||
layer.on('tileerror', () => {
|
||||
if (!loaded) {
|
||||
console.warn('MapUtils: tile error — keeping fallback grid');
|
||||
}
|
||||
});
|
||||
layer.addTo(map);
|
||||
Settings.registerMap(map);
|
||||
} catch (e) {
|
||||
console.warn('MapUtils: settings init failed, keeping fallback:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a zero-network canvas fallback grid layer.
|
||||
* @returns {L.GridLayer}
|
||||
*/
|
||||
createFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid',
|
||||
});
|
||||
layer.createTile = function (coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#07090e';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Major grid lines
|
||||
ctx.strokeStyle = 'rgba(74,163,255,0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0); ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
// Minor grid lines
|
||||
ctx.strokeStyle = 'rgba(74,163,255,0.06)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0); ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128); ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(74,163,255,0.25)';
|
||||
ctx.font = '10px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} ${coords.x},${coords.y}`, 8, 18);
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add tactical overlays to a map.
|
||||
*
|
||||
* @param {L.Map} map
|
||||
* @param {Object} [options]
|
||||
* @param {Object} [options.rangeRings]
|
||||
* { center: [lat,lng], intervals: number[], unit: 'nm'|'km' }
|
||||
* @param {Object} [options.observerReticle]
|
||||
* { latlng: [lat,lng] }
|
||||
* @param {Object} [options.hudPanels]
|
||||
* { modeName: string, getContactCount: ()=>number, getSdrStatus: ()=>boolean }
|
||||
* @param {boolean} [options.graticule]
|
||||
* @param {boolean} [options.scaleBar]
|
||||
*
|
||||
* @returns {Object} handles
|
||||
* { updateCount(n), updateStatus(online), showGraticule(), hideGraticule(),
|
||||
* updateReticle(latlng), removeAll() }
|
||||
*/
|
||||
addTacticalOverlays(map, options = {}) {
|
||||
const handles = {};
|
||||
const cleanupFns = [];
|
||||
|
||||
// --- Scale bar ---
|
||||
if (options.scaleBar !== false) {
|
||||
const scale = L.control.scale({ imperial: true, metric: true, position: 'bottomright' });
|
||||
scale.addTo(map);
|
||||
cleanupFns.push(() => scale.remove());
|
||||
}
|
||||
|
||||
// --- Range rings ---
|
||||
let rangeRingsLayer = null;
|
||||
if (options.rangeRings) {
|
||||
rangeRingsLayer = this._buildRangeRings(map, options.rangeRings);
|
||||
}
|
||||
handles.rangeRingsLayer = rangeRingsLayer;
|
||||
|
||||
// --- Observer reticle ---
|
||||
let reticleMarker = null;
|
||||
if (options.observerReticle) {
|
||||
reticleMarker = this._buildReticle(options.observerReticle.latlng);
|
||||
reticleMarker.addTo(map);
|
||||
cleanupFns.push(() => map.removeLayer(reticleMarker));
|
||||
}
|
||||
handles.updateReticle = (latlng) => {
|
||||
if (reticleMarker) reticleMarker.setLatLng(latlng);
|
||||
};
|
||||
|
||||
// --- HUD panels ---
|
||||
let hudHandles = { updateCount: () => {}, updateStatus: () => {} };
|
||||
if (options.hudPanels) {
|
||||
hudHandles = this._buildHudPanels(map, options.hudPanels);
|
||||
cleanupFns.push(() => hudHandles.remove());
|
||||
}
|
||||
handles.updateCount = hudHandles.updateCount;
|
||||
handles.updateStatus = hudHandles.updateStatus;
|
||||
|
||||
// --- Graticule ---
|
||||
let graticuleLayer = null;
|
||||
const buildGraticule = () => {
|
||||
if (graticuleLayer) map.removeLayer(graticuleLayer);
|
||||
graticuleLayer = this._buildGraticule(map);
|
||||
graticuleLayer.addTo(map);
|
||||
};
|
||||
const removeGraticule = () => {
|
||||
if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; }
|
||||
};
|
||||
if (options.graticule) {
|
||||
buildGraticule();
|
||||
map.on('zoomend', buildGraticule);
|
||||
cleanupFns.push(() => {
|
||||
map.off('zoomend', buildGraticule);
|
||||
removeGraticule();
|
||||
});
|
||||
}
|
||||
handles.showGraticule = () => {
|
||||
buildGraticule();
|
||||
map.on('zoomend', buildGraticule);
|
||||
};
|
||||
handles.hideGraticule = () => {
|
||||
map.off('zoomend', buildGraticule);
|
||||
removeGraticule();
|
||||
};
|
||||
|
||||
handles.removeAll = () => cleanupFns.forEach(fn => fn());
|
||||
|
||||
// Auto-cleanup when Leaflet map is removed
|
||||
const autoCleanup = () => {
|
||||
cleanupFns.forEach(fn => fn());
|
||||
map.off('remove', autoCleanup);
|
||||
};
|
||||
map.on('remove', autoCleanup);
|
||||
const originalRemoveAll = handles.removeAll;
|
||||
handles.removeAll = () => {
|
||||
map.off('remove', autoCleanup);
|
||||
originalRemoveAll();
|
||||
};
|
||||
|
||||
return handles;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build dashed range rings around a centre point.
|
||||
* @private
|
||||
*/
|
||||
_buildRangeRings(map, opts) {
|
||||
const { center, intervals, unit = 'nm' } = opts;
|
||||
const metersPerUnit = unit === 'km' ? 1000 : 1852;
|
||||
const layer = L.layerGroup();
|
||||
|
||||
intervals.forEach(dist => {
|
||||
const meters = dist * metersPerUnit;
|
||||
L.circle(center, {
|
||||
radius: meters,
|
||||
color: '#4aa3ff',
|
||||
fillColor: 'transparent',
|
||||
fillOpacity: 0,
|
||||
weight: 1,
|
||||
opacity: 0.3,
|
||||
dashArray: '4 4',
|
||||
interactive: false,
|
||||
}).addTo(layer);
|
||||
|
||||
// Label at accurate north point of ring (Leaflet handles earth curvature)
|
||||
const labelLat = L.circle(center, { radius: meters }).getBounds().getNorth();
|
||||
L.marker([labelLat, center[1]], {
|
||||
icon: L.divIcon({
|
||||
className: 'map-range-label',
|
||||
html: `<span>${Math.round(dist)} ${unit}</span>`,
|
||||
iconSize: [50, 14],
|
||||
iconAnchor: [25, 7],
|
||||
}),
|
||||
interactive: false,
|
||||
}).addTo(layer);
|
||||
});
|
||||
|
||||
layer.addTo(map);
|
||||
return layer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a crosshair SVG marker.
|
||||
* @private
|
||||
*/
|
||||
_buildReticle(latlng) {
|
||||
const icon = L.divIcon({
|
||||
className: 'map-reticle',
|
||||
html: `<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="14" cy="14" r="4" stroke="#4aa3ff" stroke-width="1.5"/>
|
||||
<line x1="14" y1="2" x2="14" y2="9" stroke="#4aa3ff" stroke-width="1.5"/>
|
||||
<line x1="14" y1="19" x2="14" y2="26" stroke="#4aa3ff" stroke-width="1.5"/>
|
||||
<line x1="2" y1="14" x2="9" y2="14" stroke="#4aa3ff" stroke-width="1.5"/>
|
||||
<line x1="19" y1="14" x2="26" y2="14" stroke="#4aa3ff" stroke-width="1.5"/>
|
||||
</svg>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
});
|
||||
return L.marker(latlng, { icon, interactive: false, zIndexOffset: -100 });
|
||||
},
|
||||
|
||||
/**
|
||||
* Build HUD corner panels and attach them to the map container.
|
||||
* Returns update handles.
|
||||
* @private
|
||||
*/
|
||||
_buildHudPanels(map, opts) {
|
||||
const { modeName = '', getContactCount = () => 0, getSdrStatus = () => null } = opts;
|
||||
const container = map.getContainer();
|
||||
|
||||
// Top-left: mode name + contact count
|
||||
const tl = document.createElement('div');
|
||||
tl.className = 'map-hud-panel map-hud-tl';
|
||||
tl.innerHTML = `
|
||||
<span class="map-hud-mode">${modeName}</span>
|
||||
<span class="map-hud-count">0</span>
|
||||
`;
|
||||
container.appendChild(tl);
|
||||
const countEl = tl.querySelector('.map-hud-count');
|
||||
|
||||
// Top-right: UTC clock + SDR status dot
|
||||
const tr = document.createElement('div');
|
||||
tr.className = 'map-hud-panel map-hud-tr';
|
||||
tr.innerHTML = `
|
||||
<span class="map-hud-clock"></span>
|
||||
<span class="map-hud-dot"></span>
|
||||
`;
|
||||
container.appendChild(tr);
|
||||
const clockEl = tr.querySelector('.map-hud-clock');
|
||||
const dotEl = tr.querySelector('.map-hud-dot');
|
||||
|
||||
// Clock tick
|
||||
const updateClock = () => {
|
||||
if (!document.body.contains(container)) return;
|
||||
clockEl.textContent = new Date().toISOString().substring(11, 19) + ' UTC';
|
||||
};
|
||||
updateClock();
|
||||
const clockInterval = setInterval(updateClock, 1000);
|
||||
|
||||
return {
|
||||
updateCount(n) {
|
||||
countEl.textContent = n;
|
||||
},
|
||||
updateStatus(online) {
|
||||
dotEl.className = `map-hud-dot ${online === true ? 'online' : online === false ? 'offline' : ''}`;
|
||||
},
|
||||
remove() {
|
||||
clearInterval(clockInterval);
|
||||
tl.remove();
|
||||
tr.remove();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Build a 10° lat/lon graticule as a Leaflet layer group.
|
||||
* Only draws lines visible in the current map bounds (+ 10% margin).
|
||||
* @private
|
||||
*/
|
||||
_buildGraticule(map) {
|
||||
const layer = L.layerGroup();
|
||||
const bounds = map.getBounds().pad(0.1);
|
||||
const step = 10;
|
||||
const style = { color: 'rgba(74,163,255,0.12)', weight: 1, interactive: false };
|
||||
|
||||
const latMin = Math.floor(bounds.getSouth() / step) * step;
|
||||
const latMax = Math.ceil(bounds.getNorth() / step) * step;
|
||||
const lonMin = Math.floor(bounds.getWest() / step) * step;
|
||||
const lonMax = Math.ceil(bounds.getEast() / step) * step;
|
||||
|
||||
for (let lat = latMin; lat <= latMax; lat += step) {
|
||||
if (lat < -90 || lat > 90) continue;
|
||||
L.polyline([[lat, lonMin], [lat, lonMax]], style).addTo(layer);
|
||||
}
|
||||
for (let lon = lonMin; lon <= lonMax; lon += step) {
|
||||
L.polyline([[-90, lon], [90, lon]], style).addTo(layer);
|
||||
}
|
||||
return layer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return Leaflet popup options for dark-glass style.
|
||||
* @returns {Object}
|
||||
*/
|
||||
glassPopupOptions() {
|
||||
return { className: 'map-glass-popup', maxWidth: 340 };
|
||||
},
|
||||
};
|
||||
+139
-111
@@ -36,6 +36,8 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Device list filter
|
||||
let currentDeviceFilter = 'all';
|
||||
let sortBy = 'rssi';
|
||||
let sortListenersBound = false;
|
||||
let currentSearchTerm = '';
|
||||
let visibleDeviceCount = 0;
|
||||
let pendingDeviceFlush = false;
|
||||
@@ -118,6 +120,7 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Initialize device list filters
|
||||
initDeviceFilters();
|
||||
initSortControls();
|
||||
initListInteractions();
|
||||
|
||||
// Set initial panel states
|
||||
@@ -129,7 +132,7 @@ const BluetoothMode = (function() {
|
||||
*/
|
||||
function initDeviceFilters() {
|
||||
if (filterListenersBound) return;
|
||||
const filterContainer = document.getElementById('btDeviceFilters');
|
||||
const filterContainer = document.getElementById('btFilterGroup');
|
||||
if (filterContainer) {
|
||||
filterContainer.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.bt-filter-btn');
|
||||
@@ -158,17 +161,27 @@ const BluetoothMode = (function() {
|
||||
filterListenersBound = true;
|
||||
}
|
||||
|
||||
function initSortControls() {
|
||||
if (sortListenersBound) return;
|
||||
sortListenersBound = true;
|
||||
const sortGroup = document.getElementById('btSortGroup');
|
||||
if (!sortGroup) return;
|
||||
sortGroup.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.bt-sort-btn');
|
||||
if (!btn) return;
|
||||
const sort = btn.dataset.sort;
|
||||
if (!sort) return;
|
||||
sortBy = sort;
|
||||
sortGroup.querySelectorAll('.bt-sort-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderAllDevices();
|
||||
});
|
||||
}
|
||||
|
||||
function initListInteractions() {
|
||||
if (listListenersBound) return;
|
||||
if (deviceContainer) {
|
||||
deviceContainer.addEventListener('click', (event) => {
|
||||
const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
|
||||
if (locateBtn) {
|
||||
event.preventDefault();
|
||||
locateById(locateBtn.dataset.locateId);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = event.target.closest('.bt-device-row[data-bt-device-id]');
|
||||
if (!row) return;
|
||||
selectDevice(row.dataset.btDeviceId);
|
||||
@@ -1008,6 +1021,15 @@ const BluetoothMode = (function() {
|
||||
const statusText = document.getElementById('statusText');
|
||||
if (statusDot) statusDot.classList.toggle('running', scanning);
|
||||
if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle';
|
||||
|
||||
// Drive the per-panel scan indicator
|
||||
const scanDot = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-dot');
|
||||
const scanText = document.getElementById('btScanIndicator')?.querySelector('.bt-scan-text');
|
||||
if (scanDot) scanDot.style.display = scanning ? 'inline-block' : 'none';
|
||||
if (scanText) {
|
||||
scanText.textContent = scanning ? 'SCANNING' : 'IDLE';
|
||||
scanText.classList.toggle('active', scanning);
|
||||
}
|
||||
}
|
||||
|
||||
function resetStats() {
|
||||
@@ -1366,10 +1388,30 @@ const BluetoothMode = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render all devices in the current sort order, then re-apply the active filter.
|
||||
*/
|
||||
function renderAllDevices() {
|
||||
if (!deviceContainer) return;
|
||||
if (devices.size === 0) return;
|
||||
deviceContainer.innerHTML = '';
|
||||
|
||||
const sorted = [...devices.values()].sort((a, b) => {
|
||||
if (sortBy === 'rssi') return (b.rssi_current ?? -100) - (a.rssi_current ?? -100);
|
||||
if (sortBy === 'name') return (a.name || '\uFFFF').localeCompare(b.name || '\uFFFF');
|
||||
if (sortBy === 'seen') return (b.seen_count || 0) - (a.seen_count || 0);
|
||||
if (sortBy === 'distance') return (a.estimated_distance_m ?? 9999) - (b.estimated_distance_m ?? 9999);
|
||||
return 0;
|
||||
});
|
||||
|
||||
sorted.forEach(device => renderDevice(device, false));
|
||||
applyDeviceFilter();
|
||||
if (selectedDeviceId) highlightSelectedDevice(selectedDeviceId);
|
||||
}
|
||||
|
||||
function createSimpleDeviceCard(device) {
|
||||
const protocol = device.protocol || 'ble';
|
||||
const rssi = device.rssi_current;
|
||||
const rssiColor = getRssiColor(rssi);
|
||||
const inBaseline = device.in_baseline || false;
|
||||
const isNew = !inBaseline;
|
||||
const hasName = !!device.name;
|
||||
@@ -1380,58 +1422,69 @@ const BluetoothMode = (function() {
|
||||
const agentName = device._agent || 'Local';
|
||||
const seenBefore = device.seen_before === true;
|
||||
|
||||
// Calculate RSSI bar width (0-100%)
|
||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||
// Signal bar
|
||||
const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0;
|
||||
const fillClass = rssi == null ? 'weak'
|
||||
: rssi >= -60 ? 'strong'
|
||||
: rssi >= -75 ? 'medium' : 'weak';
|
||||
|
||||
const displayName = device.name || formatDeviceId(device.address);
|
||||
const name = escapeHtml(displayName);
|
||||
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||
const seenCount = device.seen_count || 0;
|
||||
const searchIndex = [
|
||||
displayName,
|
||||
device.address,
|
||||
device.manufacturer_name,
|
||||
device.tracker_name,
|
||||
device.tracker_type,
|
||||
agentName
|
||||
displayName, device.address, device.manufacturer_name,
|
||||
device.tracker_name, device.tracker_type, agentName
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
// Protocol badge - compact
|
||||
// Protocol badge
|
||||
const protoBadge = protocol === 'ble'
|
||||
? '<span class="bt-proto-badge ble">BLE</span>'
|
||||
: '<span class="bt-proto-badge classic">CLASSIC</span>';
|
||||
|
||||
// Tracker badge - show if device is detected as tracker
|
||||
// Tracker badge
|
||||
let trackerBadge = '';
|
||||
if (isTracker) {
|
||||
const confColor = trackerConfidence === 'high' ? '#ef4444' :
|
||||
trackerConfidence === 'medium' ? '#f97316' : '#eab308';
|
||||
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' :
|
||||
trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
|
||||
const typeLabel = trackerType === 'airtag' ? 'AirTag' :
|
||||
trackerType === 'tile' ? 'Tile' :
|
||||
trackerType === 'samsung_smarttag' ? 'SmartTag' :
|
||||
trackerType === 'findmy_accessory' ? 'FindMy' :
|
||||
trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
|
||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||
const confColor = trackerConfidence === 'high' ? '#ef4444'
|
||||
: trackerConfidence === 'medium' ? '#f97316' : '#eab308';
|
||||
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)'
|
||||
: trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
|
||||
const typeLabel = trackerType === 'airtag' ? 'AirTag'
|
||||
: trackerType === 'tile' ? 'Tile'
|
||||
: trackerType === 'samsung_smarttag' ? 'SmartTag'
|
||||
: trackerType === 'findmy_accessory' ? 'FindMy'
|
||||
: trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
|
||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor
|
||||
+ ';font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;">' + typeLabel + '</span>';
|
||||
}
|
||||
|
||||
// IRK badge - show if paired IRK is available
|
||||
let irkBadge = '';
|
||||
if (device.has_irk) {
|
||||
irkBadge = '<span class="bt-irk-badge">IRK</span>';
|
||||
}
|
||||
// IRK badge
|
||||
const irkBadge = device.has_irk ? '<span class="bt-irk-badge">IRK</span>' : '';
|
||||
|
||||
// Risk badge - show if risk score is significant
|
||||
// Risk badge
|
||||
let riskBadge = '';
|
||||
if (riskScore >= 0.3) {
|
||||
const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316';
|
||||
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor + ';font-size:8px;margin-left:4px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
|
||||
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor
|
||||
+ ';font-size:8px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
|
||||
}
|
||||
|
||||
// Status indicator
|
||||
// MAC cluster badge
|
||||
const clusterBadge = device.mac_cluster_count > 1
|
||||
? '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>'
|
||||
: '';
|
||||
|
||||
// Flag badges (top-right, before status dot)
|
||||
const hFlags = device.heuristic_flags || [];
|
||||
let flagBadges = '';
|
||||
if (device.is_persistent || hFlags.includes('persistent'))
|
||||
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||
if (device.is_beacon_like || hFlags.includes('beacon_like'))
|
||||
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||
if (device.is_strong_stable || hFlags.includes('strong_stable'))
|
||||
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||
|
||||
// Status dot
|
||||
let statusDot;
|
||||
if (isTracker && trackerConfidence === 'high') {
|
||||
statusDot = '<span class="bt-status-dot tracker" style="background:#ef4444;"></span>';
|
||||
@@ -1441,74 +1494,55 @@ const BluetoothMode = (function() {
|
||||
statusDot = '<span class="bt-status-dot known"></span>';
|
||||
}
|
||||
|
||||
// Distance display
|
||||
// Bottom meta
|
||||
const metaLabel = mfr || addr; // already HTML-escaped
|
||||
const distM = device.estimated_distance_m;
|
||||
let distStr = '';
|
||||
if (distM != null) {
|
||||
distStr = '~' + distM.toFixed(1) + 'm';
|
||||
}
|
||||
const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : '';
|
||||
let metaHtml = '<span>' + metaLabel + '</span>';
|
||||
if (distStr) metaHtml += '<span>' + distStr + '</span>';
|
||||
metaHtml += '<span class="bt-row-rssi ' + fillClass + '">' + (rssi != null ? rssi : '—') + '</span>';
|
||||
if (seenBefore) metaHtml += '<span class="bt-history-badge">SEEN</span>';
|
||||
if (agentName !== 'Local')
|
||||
metaHtml += '<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">'
|
||||
+ escapeHtml(agentName) + '</span>';
|
||||
|
||||
// Behavioral flag badges
|
||||
const hFlags = device.heuristic_flags || [];
|
||||
let flagBadges = '';
|
||||
if (device.is_persistent || hFlags.includes('persistent')) {
|
||||
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||
}
|
||||
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
|
||||
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||
}
|
||||
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
|
||||
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||
}
|
||||
// Left border colour
|
||||
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444'
|
||||
: isTracker ? '#f97316'
|
||||
: rssi != null && rssi >= -60 ? 'var(--accent-green)'
|
||||
: rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)'
|
||||
: 'var(--accent-red)';
|
||||
|
||||
// MAC cluster badge
|
||||
let clusterBadge = '';
|
||||
if (device.mac_cluster_count > 1) {
|
||||
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
|
||||
}
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
if (distStr) secondaryParts.push(distStr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
// Add agent name if not Local
|
||||
if (agentName !== 'Local') {
|
||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||
}
|
||||
const secondaryInfo = secondaryParts.join(' · ');
|
||||
|
||||
// Row border color - highlight trackers in red/orange
|
||||
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
|
||||
isTracker ? '#f97316' : rssiColor;
|
||||
|
||||
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeAttr(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" role="button" tabindex="0" data-keyboard-activate="true" style="border-left-color:' + borderColor + ';">' +
|
||||
'<div class="bt-row-main">' +
|
||||
'<div class="bt-row-left">' +
|
||||
protoBadge +
|
||||
'<span class="bt-device-name">' + name + '</span>' +
|
||||
trackerBadge +
|
||||
irkBadge +
|
||||
riskBadge +
|
||||
flagBadges +
|
||||
clusterBadge +
|
||||
'</div>' +
|
||||
'<div class="bt-row-right">' +
|
||||
'<div class="bt-rssi-container">' +
|
||||
'<div class="bt-rssi-bar-bg"><div class="bt-rssi-bar" style="width:' + rssiPercent + '%;background:' + rssiColor + ';"></div></div>' +
|
||||
'<span class="bt-rssi-value" style="color:' + rssiColor + ';">' + (rssi != null ? rssi : '--') + '</span>' +
|
||||
'</div>' +
|
||||
statusDot +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
||||
'<div class="bt-row-actions">' +
|
||||
'<button type="button" class="bt-locate-btn" data-locate-id="' + escapeAttr(device.device_id) + '">' +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
|
||||
'Locate</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '"'
|
||||
+ ' data-bt-device-id="' + escapeAttr(device.device_id) + '"'
|
||||
+ ' data-is-new="' + isNew + '"'
|
||||
+ ' data-has-name="' + hasName + '"'
|
||||
+ ' data-rssi="' + (rssi ?? -100) + '"'
|
||||
+ ' data-is-tracker="' + isTracker + '"'
|
||||
+ ' data-search="' + escapeAttr(searchIndex) + '"'
|
||||
+ ' role="button" tabindex="0" data-keyboard-activate="true"'
|
||||
+ ' style="border-left-color:' + borderColor + ';">'
|
||||
// Top line
|
||||
+ '<div class="bt-row-top">'
|
||||
+ '<div class="bt-row-top-left">'
|
||||
+ protoBadge
|
||||
+ '<span class="bt-row-name' + (hasName ? '' : ' bt-unnamed') + '">' + name + '</span>'
|
||||
+ trackerBadge + irkBadge + riskBadge + clusterBadge
|
||||
+ '</div>'
|
||||
+ '<div class="bt-row-top-right">'
|
||||
+ flagBadges + statusDot
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
// Bottom line
|
||||
+ '<div class="bt-row-bottom">'
|
||||
+ '<div class="bt-signal-bar-wrap">'
|
||||
+ '<div class="bt-signal-track">'
|
||||
+ '<div class="bt-signal-fill ' + fillClass + '" style="width:' + rssiPercent.toFixed(1) + '%"></div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="bt-row-meta">' + metaHtml + '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function getRssiColor(rssi) {
|
||||
@@ -1756,14 +1790,8 @@ const BluetoothMode = (function() {
|
||||
mac_cluster_count: device.mac_cluster_count || 0
|
||||
};
|
||||
|
||||
// If BtLocate is already loaded, hand off directly
|
||||
if (typeof BtLocate !== 'undefined') {
|
||||
BtLocate.handoff(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode first — this loads the script, styles,
|
||||
// and initializes the module. Then hand off the device data.
|
||||
// Always switch to bt_locate mode first (loads script + styles if needed,
|
||||
// initializes the module), then hand off device data.
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate').then(function() {
|
||||
if (typeof BtLocate !== 'undefined') {
|
||||
|
||||
@@ -1792,11 +1792,6 @@ const BtLocate = (function() {
|
||||
const irkInput = document.getElementById('btLocateIrk');
|
||||
if (irkInput) irkInput.value = deviceInfo.irk_hex;
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate');
|
||||
}
|
||||
}
|
||||
|
||||
function clearHandoff() {
|
||||
|
||||
+74
-64
@@ -13,12 +13,14 @@ const SSTV = (function() {
|
||||
let issMap = null;
|
||||
let issMarker = null;
|
||||
let issTrackLine = null;
|
||||
let issTrackPast = null;
|
||||
let issPosition = null;
|
||||
let issUpdateInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
let locationListenersAttached = false;
|
||||
let issUpdateInterval = null;
|
||||
let issTrackInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
let locationListenersAttached = false;
|
||||
|
||||
// ISS frequency
|
||||
const ISS_FREQ = 145.800;
|
||||
@@ -93,12 +95,12 @@ const SSTV = (function() {
|
||||
if (latInput && storedLat) latInput.value = storedLat;
|
||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||
|
||||
if (!locationListenersAttached) {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
if (!locationListenersAttached) {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save location from input fields
|
||||
@@ -250,12 +252,19 @@ const SSTV = (function() {
|
||||
// Create ISS marker (will be positioned when we get data)
|
||||
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
||||
|
||||
// Create ground track line
|
||||
// Past track (dimmer, solid)
|
||||
issTrackPast = L.polyline([], {
|
||||
color: '#00d4ff',
|
||||
weight: 1.5,
|
||||
opacity: 0.3,
|
||||
}).addTo(issMap);
|
||||
|
||||
// Future track (brighter, dashed)
|
||||
issTrackLine = L.polyline([], {
|
||||
color: '#00d4ff',
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '5, 5'
|
||||
opacity: 0.7,
|
||||
dashArray: '6, 4'
|
||||
}).addTo(issMap);
|
||||
|
||||
issMap.on('resize moveend zoomend', () => {
|
||||
@@ -272,9 +281,12 @@ const SSTV = (function() {
|
||||
*/
|
||||
function startIssTracking() {
|
||||
updateIssPosition();
|
||||
// Update every 5 seconds
|
||||
updateIssTrack();
|
||||
if (issUpdateInterval) clearInterval(issUpdateInterval);
|
||||
issUpdateInterval = setInterval(updateIssPosition, 5000);
|
||||
// Track refreshes every 5 minutes — one orbit is ~93 min so this keeps it current
|
||||
if (issTrackInterval) clearInterval(issTrackInterval);
|
||||
issTrackInterval = setInterval(updateIssTrack, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +297,52 @@ const SSTV = (function() {
|
||||
clearInterval(issUpdateInterval);
|
||||
issUpdateInterval = null;
|
||||
}
|
||||
if (issTrackInterval) {
|
||||
clearInterval(issTrackInterval);
|
||||
issTrackInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and render the ISS ground track from the backend (TLE-propagated).
|
||||
*/
|
||||
async function updateIssTrack() {
|
||||
try {
|
||||
const response = await fetch('/sstv/iss-track');
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok' || !issTrackLine || !issTrackPast) return;
|
||||
|
||||
const pastPts = [], futurePts = [];
|
||||
for (const pt of data.track) {
|
||||
(pt.past ? pastPts : futurePts).push([pt.lat, pt.lon]);
|
||||
}
|
||||
|
||||
// Split future track at antimeridian crossings to avoid long horizontal lines
|
||||
const futureSegments = _splitAtAntimeridian(futurePts);
|
||||
const pastSegments = _splitAtAntimeridian(pastPts);
|
||||
|
||||
issTrackLine.setLatLngs(futureSegments);
|
||||
issTrackPast.setLatLngs(pastSegments);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch ISS track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an array of [lat, lon] points into segments at antimeridian crossings.
|
||||
*/
|
||||
function _splitAtAntimeridian(points) {
|
||||
const segments = [];
|
||||
let current = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (current.length > 1) segments.push(current);
|
||||
current = [];
|
||||
}
|
||||
current.push(points[i]);
|
||||
}
|
||||
if (current.length > 1) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,55 +544,7 @@ const SSTV = (function() {
|
||||
issMarker.setLatLng([lat, lon]);
|
||||
}
|
||||
|
||||
// Calculate and draw ground track
|
||||
if (issTrackLine) {
|
||||
const trackPoints = [];
|
||||
const inclination = 51.6; // ISS orbital inclination in degrees
|
||||
|
||||
// Generate orbit track points
|
||||
for (let offset = -180; offset <= 180; offset += 3) {
|
||||
let trackLon = lon + offset;
|
||||
|
||||
// Normalize longitude
|
||||
while (trackLon > 180) trackLon -= 360;
|
||||
while (trackLon < -180) trackLon += 360;
|
||||
|
||||
// Calculate latitude based on orbital inclination
|
||||
const phase = (offset / 360) * 2 * Math.PI;
|
||||
const currentPhase = Math.asin(Math.max(-1, Math.min(1, lat / inclination)));
|
||||
let trackLat = inclination * Math.sin(phase + currentPhase);
|
||||
|
||||
// Clamp to valid range
|
||||
trackLat = Math.max(-inclination, Math.min(inclination, trackLat));
|
||||
|
||||
trackPoints.push([trackLat, trackLon]);
|
||||
}
|
||||
|
||||
// Split track at antimeridian to avoid line across map
|
||||
const segments = [];
|
||||
let currentSegment = [];
|
||||
|
||||
for (let i = 0; i < trackPoints.length; i++) {
|
||||
if (i > 0) {
|
||||
const prevLon = trackPoints[i - 1][1];
|
||||
const currLon = trackPoints[i][1];
|
||||
if (Math.abs(currLon - prevLon) > 180) {
|
||||
// Crossed antimeridian
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
currentSegment = [];
|
||||
}
|
||||
}
|
||||
currentSegment.push(trackPoints[i]);
|
||||
}
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
|
||||
// Use only the longest segment or combine if needed
|
||||
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
||||
}
|
||||
// Track is fetched separately by updateIssTrack() via /sstv/iss-track
|
||||
|
||||
// Pan map to follow ISS only when the map pane is currently renderable.
|
||||
if (isMapContainerVisible()) {
|
||||
|
||||
@@ -1561,21 +1561,22 @@ const WeatherSat = (function() {
|
||||
const spans = labels.querySelectorAll('span');
|
||||
if (spans.length !== hours.length) return;
|
||||
|
||||
const tz = typeof InterceptTime !== 'undefined' ? InterceptTime.getTimezone() : 'UTC';
|
||||
const ianaName = typeof InterceptTime !== 'undefined' ? InterceptTime.getIANA() : undefined;
|
||||
|
||||
hours.forEach((h, i) => {
|
||||
if (selectedTimezone === 'UTC' || selectedTimezone === 'local') {
|
||||
spans[i].textContent = h === 24 ? '24:00' : `${String(h).padStart(2, '0')}:00`;
|
||||
if (h === 24) {
|
||||
spans[i].textContent = '24:00';
|
||||
return;
|
||||
}
|
||||
if (tz === 'UTC' || tz === 'local') {
|
||||
spans[i].textContent = `${String(h).padStart(2, '0')}:00`;
|
||||
} else {
|
||||
// Show timezone-adjusted labels
|
||||
const d = new Date();
|
||||
d.setHours(h, 0, 0, 0);
|
||||
const tz = TZ_MAP[selectedTimezone];
|
||||
const opts = { hour: '2-digit', minute: '2-digit', hour12: false };
|
||||
if (tz) opts.timeZone = tz;
|
||||
if (h === 24) {
|
||||
spans[i].textContent = '24:00';
|
||||
} else {
|
||||
spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
|
||||
}
|
||||
if (ianaName) opts.timeZone = ianaName;
|
||||
spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+1917
-1876
File diff suppressed because it is too large
Load Diff
+85
-120
@@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
|
||||
@@ -340,6 +341,9 @@
|
||||
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
|
||||
<option value="0">SDR 0</option>
|
||||
</select>
|
||||
<label title="SDR gain in dB (0 = auto)" style="display:flex;align-items:center;gap:3px;font-size:11px;">
|
||||
Gain <input type="number" id="adsbGainInput" value="40" min="0" max="50" step="1" style="width:46px;" title="SDR gain in dB">
|
||||
</label>
|
||||
<label class="bias-t-label" title="Enable Bias-T power for external LNA/preamp"><input type="checkbox" id="adsbBiasT" onchange="saveAdsbBiasTSetting()"> Bias-T</label>
|
||||
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
|
||||
</div>
|
||||
@@ -417,6 +421,7 @@
|
||||
// STATE
|
||||
// ============================================
|
||||
let radarMap = null;
|
||||
let mapOverlays = null;
|
||||
let aircraft = {};
|
||||
let markers = {};
|
||||
let selectedIcao = null;
|
||||
@@ -638,7 +643,6 @@
|
||||
return { lat: defaultLat, lon: defaultLon };
|
||||
})();
|
||||
let rangeRingsLayer = null;
|
||||
let observerMarker = null;
|
||||
|
||||
// GPS state
|
||||
let gpsConnected = false;
|
||||
@@ -1512,16 +1516,10 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
rangeRingsLayer.addLayer(label);
|
||||
});
|
||||
|
||||
// Observer marker
|
||||
if (observerMarker) radarMap.removeLayer(observerMarker);
|
||||
observerMarker = L.marker([observerLocation.lat, observerLocation.lon], {
|
||||
icon: L.divIcon({
|
||||
className: 'observer-marker',
|
||||
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
})
|
||||
}).bindPopup('Your Location').addTo(radarMap);
|
||||
// Observer reticle — update position via MapUtils handle
|
||||
if (mapOverlays) {
|
||||
mapOverlays.updateReticle([observerLocation.lat, observerLocation.lon]);
|
||||
}
|
||||
|
||||
rangeRingsLayer.addTo(radarMap);
|
||||
}
|
||||
@@ -2220,112 +2218,33 @@ sudo make install</code>
|
||||
now.toISOString().substring(11, 19) + ' UTC';
|
||||
}
|
||||
|
||||
function createFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid'
|
||||
});
|
||||
layer.createTile = function(coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#08121c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.14)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.08)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0);
|
||||
ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128);
|
||||
ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function upgradeRadarTilesFromSettings(fallbackTiles) {
|
||||
if (typeof Settings === 'undefined') return;
|
||||
|
||||
try {
|
||||
await Settings.init();
|
||||
if (!radarMap) return;
|
||||
|
||||
const configuredLayer = Settings.createTileLayer();
|
||||
let tileLoaded = false;
|
||||
|
||||
configuredLayer.once('load', () => {
|
||||
tileLoaded = true;
|
||||
if (radarMap && fallbackTiles && radarMap.hasLayer(fallbackTiles)) {
|
||||
radarMap.removeLayer(fallbackTiles);
|
||||
}
|
||||
});
|
||||
|
||||
configuredLayer.on('tileerror', () => {
|
||||
if (!tileLoaded) {
|
||||
console.warn('ADS-B tile layer failed to load, keeping fallback grid');
|
||||
}
|
||||
});
|
||||
|
||||
configuredLayer.addTo(radarMap);
|
||||
Settings.registerMap(radarMap);
|
||||
} catch (e) {
|
||||
console.warn('ADS-B: Settings/tile upgrade failed, using fallback grid:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
// Guard against double initialization (e.g. bfcache restore)
|
||||
const container = document.getElementById('radarMap');
|
||||
if (!container || container._leaflet_id) return;
|
||||
|
||||
radarMap = L.map('radarMap', {
|
||||
radarMap = MapUtils.init('radarMap', {
|
||||
center: [observerLocation.lat, observerLocation.lon],
|
||||
zoom: 7,
|
||||
minZoom: 3,
|
||||
maxZoom: 15
|
||||
maxZoom: 15,
|
||||
});
|
||||
|
||||
// Use settings manager for tile layer (allows runtime changes)
|
||||
if (!radarMap) return;
|
||||
window.radarMap = radarMap;
|
||||
|
||||
// Use a zero-network fallback so dashboard navigation stays fast even
|
||||
// when internet map providers are slow or unreachable.
|
||||
const fallbackTiles = createFallbackGridLayer().addTo(radarMap);
|
||||
// Fix map size after init
|
||||
setTimeout(() => { if (radarMap) radarMap.invalidateSize(); }, 200);
|
||||
setTimeout(() => { if (radarMap) radarMap.invalidateSize(); }, 500);
|
||||
|
||||
// Draw range rings after map is ready
|
||||
setTimeout(() => drawRangeRings(), 100);
|
||||
|
||||
// Fix map size on mobile after initialization
|
||||
setTimeout(() => {
|
||||
if (radarMap) radarMap.invalidateSize();
|
||||
}, 200);
|
||||
|
||||
// Additional invalidateSize to ensure all tiles load
|
||||
setTimeout(() => {
|
||||
if (radarMap) radarMap.invalidateSize();
|
||||
}, 500);
|
||||
|
||||
// Upgrade tiles via Settings in the background without tearing down
|
||||
// the local fallback grid until a real tile layer actually loads.
|
||||
upgradeRadarTilesFromSettings(fallbackTiles);
|
||||
// Tactical overlays: observer reticle + HUD panels + scale bar
|
||||
// Range rings are managed by drawRangeRings() which handles toggle, range changes, and observer moves
|
||||
mapOverlays = MapUtils.addTacticalOverlays(radarMap, {
|
||||
observerReticle: { latlng: [observerLocation.lat, observerLocation.lon] },
|
||||
hudPanels: {
|
||||
modeName: 'ADS-B',
|
||||
getContactCount: () => Object.keys(aircraft).length,
|
||||
},
|
||||
scaleBar: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle window resize for map (especially important on mobile)
|
||||
@@ -2445,6 +2364,7 @@ sudo make install</code>
|
||||
const requestBody = {
|
||||
device: adsbDevice,
|
||||
sdr_type: adsbSdrType,
|
||||
gain: parseInt(document.getElementById('adsbGainInput')?.value || '40'),
|
||||
bias_t: getBiasTEnabled()
|
||||
};
|
||||
if (remoteConfig) {
|
||||
@@ -2939,6 +2859,8 @@ sudo make install</code>
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
|
||||
if (mapOverlays) mapOverlays.updateCount(Object.keys(aircraft).length);
|
||||
|
||||
checkAndAlertAircraft(icao, aircraft[icao]);
|
||||
updateStatistics(icao, aircraft[icao]);
|
||||
|
||||
@@ -2974,12 +2896,17 @@ sudo make install</code>
|
||||
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
|
||||
const callsign = ac.callsign || icao;
|
||||
const alt = ac.altitude ? ac.altitude + ' ft' : 'N/A';
|
||||
const typeLabel = ac.type_desc || ac.type_code || '';
|
||||
const iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
|
||||
const isSelected = icao === selectedIcao;
|
||||
|
||||
const prevState = markerState[icao] || {};
|
||||
const iconChanged = prevState.rotation !== rotation || prevState.color !== color || prevState.iconType !== iconType || prevState.isSelected !== isSelected;
|
||||
const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt;
|
||||
const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt || prevState.typeLabel !== typeLabel;
|
||||
|
||||
const tooltipContent = typeLabel
|
||||
? `${callsign}<br><span style="opacity:0.75;font-size:10px">${typeLabel}</span><br>${alt}`
|
||||
: `${callsign}<br>${alt}`;
|
||||
|
||||
if (markers[icao]) {
|
||||
markers[icao].setLatLng([ac.lat, ac.lon]);
|
||||
@@ -2988,7 +2915,7 @@ sudo make install</code>
|
||||
}
|
||||
if (tooltipChanged) {
|
||||
markers[icao].unbindTooltip();
|
||||
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
||||
markers[icao].bindTooltip(tooltipContent, {
|
||||
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
||||
});
|
||||
}
|
||||
@@ -2996,24 +2923,32 @@ sudo make install</code>
|
||||
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
||||
.addTo(radarMap)
|
||||
.on('click', () => selectAircraft(icao, 'map'));
|
||||
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
||||
markers[icao].bindTooltip(tooltipContent, {
|
||||
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
||||
});
|
||||
}
|
||||
|
||||
markerState[icao] = { rotation, color, callsign, alt, iconType, isSelected };
|
||||
markerState[icao] = { rotation, color, callsign, alt, typeLabel, iconType, isSelected };
|
||||
}
|
||||
|
||||
// Aircraft type icon SVG paths
|
||||
const AIRCRAFT_ICONS = {
|
||||
// Widebody: wide wingspan, wide tail — 747, 777, A330, A380 etc.
|
||||
widebody: 'M12 2L7 10H2v2l10 4 10-4v-2h-5L12 2zm0 14l-7 3v1h14v-1l-7-3z',
|
||||
// Narrowbody / default jet — A320, B737 etc.
|
||||
jet: 'M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z',
|
||||
// Business jet: narrow swept wings set further aft
|
||||
bizjet: 'M12 2L11 11H7v2l5 2.5 5-2.5v-2h-4L12 2zm0 13l-4 2v1h8v-1l-4-2z',
|
||||
// Turboprop: straight high-aspect wings, engines forward
|
||||
turboprop: 'M12 2L10 8H3v2.5l9 3.5 9-3.5V8h-7L12 2zm0 13l-5 2.5v1h10v-1l-5-2.5z',
|
||||
helicopter: 'M12 4L10 6H8V8h1l3 8 3-8h1V6h-2L12 4zm-1 14v2H9v1h6v-1h-2v-2h-2zm7-7h-2v2h2v-2zM4 11h2v2H4v-2z',
|
||||
// Light piston GA — C172, PA28 etc.
|
||||
prop: 'M12 3L9 8H5v2l7 6 7-6v-2h-4L12 3zm0 12l-4 2v1h8v-1l-4-2z',
|
||||
military: 'M12 2L7 9H3l1 3 8 6 8-6 1-3h-4L12 2zm0 14l-5 2.5V20h10v-1.5L12 16z',
|
||||
glider: 'M12 4L10 8H4v1.5l8 4 8-4V8h-6L12 4zm0 10l-6 2v1h12v-1l-6-2z'
|
||||
};
|
||||
|
||||
// Determine aircraft type from type_code
|
||||
// Determine aircraft icon type from ICAO type_code
|
||||
function getAircraftIconType(typeCode, isMilitary) {
|
||||
if (isMilitary) return 'military';
|
||||
if (!typeCode) return 'jet';
|
||||
@@ -3022,22 +2957,51 @@ sudo make install</code>
|
||||
|
||||
// Helicopters
|
||||
if (code.startsWith('H') || code.includes('HELI') ||
|
||||
['R22', 'R44', 'R66', 'EC35', 'EC45', 'AS50', 'AS55', 'AS65', 'B06', 'B212', 'B412', 'S76', 'A109', 'AW139', 'AW169'].some(h => code.includes(h))) {
|
||||
['R22', 'R44', 'R66', 'EC35', 'EC45', 'AS50', 'AS55', 'AS65', 'B06', 'B212', 'B412',
|
||||
'S76', 'S92', 'A109', 'AW139', 'AW169', 'AW189', 'EC25', 'EC30', 'EC75', 'EC85',
|
||||
'MI8', 'MI17', 'MI26', 'CH47', 'UH60', 'UH72', 'NH90'].some(h => code.includes(h))) {
|
||||
return 'helicopter';
|
||||
}
|
||||
|
||||
// Gliders
|
||||
if (code.startsWith('G') || code.includes('GLID')) {
|
||||
// Gliders / motorgliders
|
||||
if (code.startsWith('G') || ['GLID', 'DG1', 'DG2', 'DG3', 'DG4', 'DG5', 'ASK', 'ASW',
|
||||
'LS4', 'LS6', 'LS8', 'DUET', 'DISC', 'NIMB', 'PUCH', 'VENT'].some(g => code.includes(g))) {
|
||||
return 'glider';
|
||||
}
|
||||
|
||||
// Light props (common GA aircraft)
|
||||
if (['C150', 'C152', 'C172', 'C182', 'C206', 'C208', 'C210', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'SR20', 'SR22', 'DA40', 'DA42', 'TB20', 'M20', 'BE35', 'BE36', 'BE58'].some(p => code.includes(p))) {
|
||||
return 'prop';
|
||||
// Widebody jets (twin-aisle)
|
||||
if (['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S',
|
||||
'B762', 'B763', 'B764', 'B772', 'B773', 'B77L', 'B77W', 'B778', 'B779',
|
||||
'B788', 'B789', 'B78X',
|
||||
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339',
|
||||
'A342', 'A343', 'A345', 'A346', 'A359', 'A35K', 'A388',
|
||||
'IL86', 'IL96', 'MD11', 'DC10', 'L101'].some(w => code.startsWith(w) || code === w)) {
|
||||
return 'widebody';
|
||||
}
|
||||
|
||||
// Turboprops
|
||||
if (['ATR', 'DH8', 'DHC', 'SF34', 'J328', 'B190', 'PC12', 'TBM'].some(t => code.includes(t))) {
|
||||
// Business jets
|
||||
if (['C25', 'C50', 'C51', 'C52', 'C55', 'C56', 'C65', 'C68', 'C70', 'C75',
|
||||
'GLF', 'GLEX', 'G150', 'G200', 'G280', 'G450', 'G500', 'G550', 'G600', 'G650',
|
||||
'LJ2', 'LJ3', 'LJ4', 'LJ5', 'LJ6', 'LJ7',
|
||||
'F2TH', 'F900', 'F7X', 'F8X', 'DA50',
|
||||
'CL30', 'CL35', 'CL60', 'CRJ1', 'CRJ2',
|
||||
'E135', 'E145', 'PC24', 'BE40', 'HA4T', 'PRM1'].some(b => code.startsWith(b))) {
|
||||
return 'bizjet';
|
||||
}
|
||||
|
||||
// Turboprops (regional airliners and utility)
|
||||
if (['ATR', 'DH8', 'DHC', 'SF34', 'J328', 'B190', 'PC12', 'TBM', 'C208',
|
||||
'PAY', 'BE99', 'BE9L', 'SW4', 'IL18', 'AN24', 'AN26', 'AN28',
|
||||
'F27', 'F50', 'JS31', 'JS32', 'JS41', 'MA60', 'Y12'].some(t => code.startsWith(t) || code.includes(t))) {
|
||||
return 'turboprop';
|
||||
}
|
||||
|
||||
// Light piston GA
|
||||
if (['C150', 'C152', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337',
|
||||
'PA18', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46',
|
||||
'SR20', 'SR22', 'DA40', 'DA42', 'TB20', 'TB9',
|
||||
'M20', 'BE35', 'BE36', 'BE58', 'BE60',
|
||||
'RV6', 'RV7', 'RV8', 'RV9', 'RV10'].some(p => code.startsWith(p) || code.includes(p))) {
|
||||
return 'prop';
|
||||
}
|
||||
|
||||
@@ -3046,7 +3010,7 @@ sudo make install</code>
|
||||
|
||||
function createMarkerIcon(rotation, color, iconType = 'jet', isSelected = false) {
|
||||
const path = AIRCRAFT_ICONS[iconType] || AIRCRAFT_ICONS.jet;
|
||||
const size = iconType === 'helicopter' ? 22 : 24;
|
||||
const size = iconType === 'helicopter' ? 22 : iconType === 'widebody' ? 26 : iconType === 'bizjet' ? 22 : 24;
|
||||
const glowColor = isSelected ? 'rgba(255,255,255,0.9)' : color;
|
||||
const glowSize = isSelected ? '10px' : '5px';
|
||||
const trackingRing = isSelected ?
|
||||
@@ -5750,6 +5714,7 @@ sudo make install</code>
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
{% include 'partials/nav-utility-modals.html' %}
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
|
||||
<!-- Deferred scripts -->
|
||||
<script>
|
||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||
@@ -203,6 +204,7 @@
|
||||
|
||||
// State
|
||||
let vesselMap = null;
|
||||
let aisMapOverlays = null;
|
||||
let vessels = {};
|
||||
let markers = {};
|
||||
let selectedMmsi = null;
|
||||
@@ -387,48 +389,6 @@
|
||||
15: 'Undefined'
|
||||
};
|
||||
|
||||
// Initialize map
|
||||
function createFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid'
|
||||
});
|
||||
layer.createTile = function(coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#07131c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(34, 197, 94, 0.10)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0);
|
||||
ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128);
|
||||
ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
// Guard against double initialization (e.g. bfcache restore)
|
||||
const container = document.getElementById('vesselMap');
|
||||
@@ -439,34 +399,24 @@
|
||||
document.getElementById('obsLon').value = observerLocation.lon;
|
||||
}
|
||||
|
||||
vesselMap = L.map('vesselMap', {
|
||||
center: [observerLocation.lat, observerLocation.lon],
|
||||
zoom: 10,
|
||||
zoomControl: true
|
||||
vesselMap = MapUtils.init('vesselMap', {
|
||||
center: [observerLocation.lat || 51.5, observerLocation.lon || -0.1],
|
||||
zoom: 6,
|
||||
minZoom: 2,
|
||||
maxZoom: 18,
|
||||
});
|
||||
|
||||
// Use settings manager for tile layer (allows runtime changes)
|
||||
if (!vesselMap) return;
|
||||
window.vesselMap = vesselMap;
|
||||
|
||||
// Use a zero-network fallback so dashboard navigation stays fast even
|
||||
// when internet map providers are slow or unreachable.
|
||||
const fallbackTiles = createFallbackGridLayer().addTo(vesselMap);
|
||||
setTimeout(() => { if (vesselMap) vesselMap.invalidateSize(); }, 200);
|
||||
|
||||
// Then try to upgrade tiles via Settings (non-blocking)
|
||||
if (typeof Settings !== 'undefined') {
|
||||
try {
|
||||
await Promise.race([
|
||||
Settings.init(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||
]);
|
||||
vesselMap.removeLayer(fallbackTiles);
|
||||
Settings.createTileLayer().addTo(vesselMap);
|
||||
Settings.registerMap(vesselMap);
|
||||
} catch (e) {
|
||||
console.warn('Settings init failed/timed out, using fallback tiles:', e);
|
||||
// fallback tiles already added above
|
||||
}
|
||||
}
|
||||
aisMapOverlays = MapUtils.addTacticalOverlays(vesselMap, {
|
||||
hudPanels: {
|
||||
modeName: 'AIS',
|
||||
getContactCount: () => Object.keys(vessels).length,
|
||||
},
|
||||
scaleBar: true,
|
||||
});
|
||||
|
||||
// Add observer marker
|
||||
observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], {
|
||||
@@ -794,6 +744,7 @@
|
||||
vessels[mmsi] = data;
|
||||
stats.totalVesselsSeen.add(mmsi);
|
||||
stats.messagesReceived++;
|
||||
if (aisMapOverlays) aisMapOverlays.updateCount(Object.keys(vessels).length);
|
||||
|
||||
// Update statistics
|
||||
if (data.speed && data.speed > stats.fastestSpeed) {
|
||||
@@ -1637,6 +1588,7 @@
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
{% include 'partials/nav-utility-modals.html' %}
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof VoiceAlerts !== 'undefined') {
|
||||
|
||||
+281
-228
@@ -66,6 +66,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-waveform.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
|
||||
<!-- Deferred scripts - Leaflet, Chart.js, observer-location -->
|
||||
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||
@@ -834,15 +835,19 @@
|
||||
<span class="wifi-status-label">Hidden:</span>
|
||||
<span class="wifi-status-value" id="wifiHiddenCount">0</span>
|
||||
</div>
|
||||
<div class="wifi-status-item" id="wifiScanStatus">
|
||||
<span class="wifi-status-indicator idle"></span>
|
||||
<span>Ready</span>
|
||||
<div class="wifi-status-item">
|
||||
<span class="wifi-status-label">Open:</span>
|
||||
<span class="wifi-status-value" id="wifiOpenCount" style="color:var(--accent-red);">0</span>
|
||||
</div>
|
||||
<div class="wifi-scan-indicator" id="wifiScanIndicator">
|
||||
<span class="wifi-scan-dot"></span>
|
||||
<span class="wifi-scan-text">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content: 3-column layout -->
|
||||
<div class="wifi-main-content">
|
||||
<!-- LEFT: Networks Table -->
|
||||
<!-- LEFT: Networks List -->
|
||||
<div class="wifi-networks-panel">
|
||||
<div class="wifi-networks-header">
|
||||
<h5>Discovered Networks</h5>
|
||||
@@ -853,39 +858,73 @@
|
||||
<button class="wifi-filter-btn" data-filter="open">Open</button>
|
||||
<button class="wifi-filter-btn" data-filter="hidden">Hidden</button>
|
||||
</div>
|
||||
<div class="wifi-sort-controls">
|
||||
<span class="wifi-sort-label">Sort:</span>
|
||||
<button class="wifi-sort-btn active" data-sort="rssi">Signal</button>
|
||||
<button class="wifi-sort-btn" data-sort="essid">SSID</button>
|
||||
<button class="wifi-sort-btn" data-sort="channel">Ch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-networks-table-wrapper">
|
||||
<table class="wifi-networks-table" id="wifiNetworkTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-sort="essid">SSID</th>
|
||||
<th class="sortable" data-sort="bssid">BSSID</th>
|
||||
<th class="sortable" data-sort="channel">Ch</th>
|
||||
<th class="sortable" data-sort="rssi">Signal</th>
|
||||
<th class="sortable" data-sort="security">Security</th>
|
||||
<th class="sortable" data-sort="clients">Clients</th>
|
||||
<th class="col-agent sortable" data-sort="agent">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="wifiNetworkTableBody">
|
||||
<tr class="wifi-network-placeholder">
|
||||
<td colspan="7">
|
||||
<div class="placeholder-text">Start scanning to discover networks</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="wifiNetworkList" class="wifi-network-list">
|
||||
<div class="wifi-network-placeholder">
|
||||
<p>No networks detected.<br>Start a scan to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Proximity Radar -->
|
||||
<div class="wifi-radar-panel">
|
||||
<h5>Proximity Radar</h5>
|
||||
<div id="wifiProximityRadar" class="wifi-radar-container"></div>
|
||||
<div id="wifiProximityRadar" class="wifi-radar-container">
|
||||
<svg width="100%" viewBox="0 0 210 210" id="wifiRadarSvg">
|
||||
<defs>
|
||||
<clipPath id="wifi-radar-clip">
|
||||
<circle cx="105" cy="105" r="100"/>
|
||||
</clipPath>
|
||||
<filter id="wifi-glow-sm">
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<filter id="wifi-glow-md">
|
||||
<feGaussianBlur stdDeviation="4" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background rings (static) -->
|
||||
<circle cx="105" cy="105" r="100" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.12"/>
|
||||
<circle cx="105" cy="105" r="70" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.18"/>
|
||||
<circle cx="105" cy="105" r="40" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.25"/>
|
||||
<circle cx="105" cy="105" r="15" fill="none" stroke="#00b4d8" stroke-width="0.5" opacity="0.35"/>
|
||||
|
||||
<!-- Crosshairs -->
|
||||
<line x1="5" y1="105" x2="205" y2="105" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
|
||||
<line x1="105" y1="5" x2="105" y2="205" stroke="#00b4d8" stroke-width="0.3" opacity="0.1"/>
|
||||
|
||||
<!-- Rotating sweep group -->
|
||||
<g class="wifi-radar-sweep" clip-path="url(#wifi-radar-clip)">
|
||||
<!-- Primary trailing arc: 60° -->
|
||||
<path d="M105,105 L105,5 A100,100 0 0,1 191.6,155 Z" fill="#00b4d8" opacity="0.08"/>
|
||||
<!-- Secondary trailing arc: 90° -->
|
||||
<path d="M105,105 L105,5 A100,100 0 0,1 205,105 Z" fill="#00b4d8" opacity="0.04"/>
|
||||
<!-- Sweep line -->
|
||||
<line x1="105" y1="105" x2="105" y2="5" stroke="#00b4d8" stroke-width="1.5" opacity="0.7"
|
||||
filter="url(#wifi-glow-sm)"/>
|
||||
</g>
|
||||
|
||||
<!-- Centre dot -->
|
||||
<circle cx="105" cy="105" r="3" fill="#00b4d8" opacity="0.8"/>
|
||||
|
||||
<!-- Network dots (managed by renderRadar()) -->
|
||||
<g id="wifiRadarDots"></g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="wifi-zone-summary">
|
||||
<div class="wifi-zone near">
|
||||
<span class="wifi-zone-count" id="wifiZoneImmediate">0</span>
|
||||
<span class="wifi-zone-label">Near</span>
|
||||
<span class="wifi-zone-label">Close</span>
|
||||
</div>
|
||||
<div class="wifi-zone mid">
|
||||
<span class="wifi-zone-count" id="wifiZoneNear">0</span>
|
||||
@@ -898,95 +937,104 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Channel Analysis + Security -->
|
||||
<!-- RIGHT: Channel Heatmap + Security Ring -->
|
||||
<div class="wifi-analysis-panel">
|
||||
<div class="wifi-channel-section">
|
||||
<h5>Channel Analysis</h5>
|
||||
<div class="wifi-channel-tabs" id="wifiChannelBandTabs">
|
||||
<button class="channel-band-tab active" data-band="2.4">2.4 GHz</button>
|
||||
<button class="channel-band-tab" data-band="5">5 GHz</button>
|
||||
</div>
|
||||
<div id="wifiChannelChart" class="wifi-channel-chart"></div>
|
||||
<div class="wifi-analysis-panel-header">
|
||||
<span class="panel-title" id="wifiRightPanelTitle">Channel Heatmap</span>
|
||||
<button class="wifi-detail-back-btn" id="wifiDetailBackBtn"
|
||||
style="display:none" onclick="WiFiMode.closeDetail()">← Back</button>
|
||||
</div>
|
||||
<div class="wifi-security-section">
|
||||
<h5>Security Overview</h5>
|
||||
<div class="wifi-security-stats">
|
||||
<div class="wifi-security-item wpa3">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WPA3</span>
|
||||
<span class="wifi-security-count" id="wpa3Count">0</span>
|
||||
</div>
|
||||
<div class="wifi-security-item wpa2">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WPA2</span>
|
||||
<span class="wifi-security-count" id="wpa2Count">0</span>
|
||||
</div>
|
||||
<div class="wifi-security-item wep">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WEP</span>
|
||||
<span class="wifi-security-count" id="wepCount">0</span>
|
||||
</div>
|
||||
<div class="wifi-security-item open">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>Open</span>
|
||||
<span class="wifi-security-count" id="openCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Drawer (slides up on network selection) -->
|
||||
<div class="wifi-detail-drawer" id="wifiDetailDrawer">
|
||||
<div class="wifi-detail-header">
|
||||
<div class="wifi-detail-title">
|
||||
<span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span>
|
||||
<span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span>
|
||||
</div>
|
||||
<button class="wfl-locate-btn" onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()" title="Locate this AP">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>
|
||||
Locate
|
||||
</button>
|
||||
<button class="wifi-detail-close" onclick="WiFiMode.closeDetail()">×</button>
|
||||
</div>
|
||||
<div class="wifi-detail-content" id="wifiDetailContent">
|
||||
<div class="wifi-detail-grid">
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Signal</span>
|
||||
<span class="value" id="wifiDetailRssi">--</span>
|
||||
<!-- Default: heatmap + security ring -->
|
||||
<div id="wifiHeatmapView" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
|
||||
<div class="wifi-heatmap-wrap">
|
||||
<div class="wifi-heatmap-label">
|
||||
2.4 GHz · Last <span id="wifiHeatmapCount">0</span> scans
|
||||
</div>
|
||||
<div class="wifi-heatmap-ch-labels" id="wifiHeatmapChLabels">
|
||||
<!-- 11 channel labels (1–11), generated once by JS -->
|
||||
</div>
|
||||
<div class="wifi-heatmap-grid" id="wifiHeatmapGrid"></div>
|
||||
<div class="wifi-heatmap-legend">
|
||||
<span>Low</span>
|
||||
<div class="wifi-heatmap-legend-grad"></div>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Channel</span>
|
||||
<span class="value" id="wifiDetailChannel">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Band</span>
|
||||
<span class="value" id="wifiDetailBand">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Security</span>
|
||||
<span class="value" id="wifiDetailSecurity">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Cipher</span>
|
||||
<span class="value" id="wifiDetailCipher">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Vendor</span>
|
||||
<span class="value" id="wifiDetailVendor">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Clients</span>
|
||||
<span class="value" id="wifiDetailClients">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">First Seen</span>
|
||||
<span class="value" id="wifiDetailFirstSeen">--</span>
|
||||
<div class="wifi-security-ring-wrap">
|
||||
<svg id="wifiSecurityRingSvg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>
|
||||
</svg>
|
||||
<div class="wifi-security-ring-legend" id="wifiSecurityRingLegend"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
|
||||
<h6>Connected Clients <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></h6>
|
||||
<div class="wifi-client-list"></div>
|
||||
|
||||
<!-- On network click: detail panel (wired in Task 5) -->
|
||||
<div id="wifiDetailView" style="display:none; flex:1; overflow-y:auto;">
|
||||
<div class="wifi-detail-inner">
|
||||
<div class="wifi-detail-head">
|
||||
<div class="wifi-detail-essid" id="wifiDetailEssid">—</div>
|
||||
<div class="wifi-detail-bssid" id="wifiDetailBssid">—</div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-detail-signal-bar">
|
||||
<div class="wifi-detail-signal-labels">
|
||||
<span>Signal</span>
|
||||
<span id="wifiDetailRssi">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-signal-track">
|
||||
<div class="wifi-detail-signal-fill" id="wifiDetailSignalFill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-detail-grid">
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Channel</span>
|
||||
<span class="value" id="wifiDetailChannel">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Band</span>
|
||||
<span class="value" id="wifiDetailBand">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Security</span>
|
||||
<span class="value" id="wifiDetailSecurity">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Cipher</span>
|
||||
<span class="value" id="wifiDetailCipher">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Clients</span>
|
||||
<span class="value" id="wifiDetailClients">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">First Seen</span>
|
||||
<span class="value" id="wifiDetailFirstSeen">—</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat" style="grid-column: 1 / -1;">
|
||||
<span class="label">Vendor</span>
|
||||
<span class="value" id="wifiDetailVendor" style="font-size:11px;">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
|
||||
<h6>Connected Clients <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></h6>
|
||||
<div class="wifi-client-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-detail-actions">
|
||||
<button class="wfl-locate-btn" title="Locate this AP"
|
||||
onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
<path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
|
||||
</svg>
|
||||
Locate
|
||||
</button>
|
||||
<button class="wifi-detail-close-btn" onclick="WiFiMode.closeDetail()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1139,6 +1187,10 @@
|
||||
<div class="wifi-device-list-header">
|
||||
<h5>Bluetooth Devices</h5>
|
||||
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
|
||||
<div class="bt-scan-indicator" id="btScanIndicator">
|
||||
<span class="bt-scan-dot" style="display:none;"></span>
|
||||
<span class="bt-scan-text">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-list-summary" id="btListSummary">
|
||||
<div class="bt-summary-item">
|
||||
@@ -1178,16 +1230,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-controls-row">
|
||||
<div class="bt-sort-group" id="btSortGroup">
|
||||
<span class="bt-sort-label">Sort</span>
|
||||
<button class="bt-sort-btn active" data-sort="rssi">Signal</button>
|
||||
<button class="bt-sort-btn" data-sort="name">Name</button>
|
||||
<button class="bt-sort-btn" data-sort="seen">Seen</button>
|
||||
<button class="bt-sort-btn" data-sort="distance">Dist</button>
|
||||
</div>
|
||||
<div class="bt-filter-group" id="btFilterGroup">
|
||||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-device-toolbar">
|
||||
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
|
||||
</div>
|
||||
<div class="bt-device-filters" id="btDeviceFilters">
|
||||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||||
</div>
|
||||
<div class="wifi-device-list-content" id="btDeviceListContent">
|
||||
<div class="app-collection-state is-empty">Start scanning to discover Bluetooth devices</div>
|
||||
</div>
|
||||
@@ -4190,6 +4251,9 @@
|
||||
// Initialize dropdown nav active state
|
||||
updateDropdownActiveState();
|
||||
|
||||
// Restore nav group open/closed state from localStorage
|
||||
initNavGroupState();
|
||||
|
||||
// Start SDR device status polling
|
||||
startSdrStatusPolling();
|
||||
|
||||
@@ -4217,6 +4281,45 @@
|
||||
if (!isOpen) {
|
||||
dropdown.classList.add('open');
|
||||
}
|
||||
saveNavGroupState();
|
||||
}
|
||||
|
||||
function initNavGroupState() {
|
||||
const NAV_STATE_KEY = 'intercept_nav_groups';
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(localStorage.getItem(NAV_STATE_KEY) || '{}');
|
||||
} catch (e) {
|
||||
savedState = {};
|
||||
}
|
||||
|
||||
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
|
||||
const group = dropdown.dataset.group;
|
||||
// If saved state says closed AND this group has no active item, close it
|
||||
if (savedState[group] === false) {
|
||||
const hasActive = dropdown.classList.contains('has-active');
|
||||
if (!hasActive) {
|
||||
dropdown.classList.remove('open');
|
||||
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
} else if (savedState[group] === true) {
|
||||
dropdown.classList.add('open');
|
||||
const btn = dropdown.querySelector('.mode-nav-dropdown-btn');
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveNavGroupState() {
|
||||
const NAV_STATE_KEY = 'intercept_nav_groups';
|
||||
const state = {};
|
||||
document.querySelectorAll('.mode-nav-dropdown[data-group]').forEach(dropdown => {
|
||||
state[dropdown.dataset.group] = dropdown.classList.contains('open');
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(NAV_STATE_KEY, JSON.stringify(state));
|
||||
} catch (e) { /* storage full or unavailable */ }
|
||||
}
|
||||
|
||||
function closeAllDropdowns() {
|
||||
@@ -5369,8 +5472,11 @@
|
||||
// Clear existing output
|
||||
output.innerHTML = '<div class="placeholder signal-empty-state" style="display: none;"></div>';
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
showInfo('Error: ' + (data.message || 'Failed to start sensor'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showInfo('Error starting sensor: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5608,8 +5714,8 @@
|
||||
|
||||
// Keep list manageable
|
||||
const cards = output.querySelectorAll('.signal-card');
|
||||
while (cards.length > 100) {
|
||||
output.removeChild(output.lastChild);
|
||||
for (let i = cards.length - 1; i >= 100; i--) {
|
||||
cards[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5892,14 +5998,8 @@
|
||||
|
||||
// Limit to max 50 unique meters (cards won't pile up since we update in place)
|
||||
const cards = output.querySelectorAll('.signal-card.meter-aggregated');
|
||||
while (cards.length > 50) {
|
||||
// Remove oldest card (last one)
|
||||
const oldestCard = output.querySelector('.signal-card.meter-aggregated:last-of-type');
|
||||
if (oldestCard) {
|
||||
output.removeChild(oldestCard);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
for (let i = cards.length - 1; i >= 50; i--) {
|
||||
cards[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6123,8 +6223,8 @@
|
||||
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
|
||||
'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 },
|
||||
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
|
||||
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 },
|
||||
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 }
|
||||
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 102 }, // LNA(40)+VGA(62)
|
||||
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 45 } // LNA(15)+Mix(15)+VGA(15)
|
||||
};
|
||||
|
||||
// Current device list with SDR type info
|
||||
@@ -6253,6 +6353,12 @@
|
||||
if (caps) {
|
||||
document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`;
|
||||
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
|
||||
// Update max attribute on all mode gain inputs so constraints match the SDR
|
||||
const gainMax = caps.gain_max;
|
||||
['gain', 'sensorGain', 'aisGainInput', 'acarsGainInput', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.max = gainMax;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6374,6 +6480,14 @@
|
||||
function saveBiasTSetting() {
|
||||
const enabled = document.getElementById('biasT')?.checked || false;
|
||||
localStorage.setItem('biasTEnabled', enabled);
|
||||
// Warn if any SDR mode is currently running — bias-T is applied at
|
||||
// start time and cannot be toggled on a running device.
|
||||
const anyRunning = isRunning || isSensorRunning
|
||||
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning)
|
||||
|| (typeof isAisRunning !== 'undefined' && isAisRunning);
|
||||
if (anyRunning) {
|
||||
showInfo('Bias-T change will take effect after restarting the active SDR mode');
|
||||
}
|
||||
}
|
||||
|
||||
function getBiasTEnabled() {
|
||||
@@ -7077,8 +7191,8 @@
|
||||
|
||||
// Limit messages displayed (keep placeholder/empty-state)
|
||||
const cards = output.querySelectorAll('.signal-card');
|
||||
while (cards.length > 100) {
|
||||
output.removeChild(cards[cards.length - 1]);
|
||||
for (let i = cards.length - 1; i >= 100; i--) {
|
||||
cards[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7442,7 +7556,7 @@
|
||||
|
||||
// Limit displayed devices
|
||||
while (content.children.length > 50) {
|
||||
content.removeChild(content.lastChild);
|
||||
content.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9961,6 +10075,7 @@
|
||||
// APRS Functions
|
||||
// ============================================
|
||||
let aprsMap = null;
|
||||
let aprsMapOverlays = null;
|
||||
let aprsMarkers = {};
|
||||
let aprsEventSource = null;
|
||||
let isAprsRunning = false;
|
||||
@@ -10112,47 +10227,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function createAprsFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid'
|
||||
});
|
||||
layer.createTile = function(coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#08121c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0);
|
||||
ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128);
|
||||
ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function initAprsMap() {
|
||||
if (aprsMap) return;
|
||||
|
||||
@@ -10181,26 +10255,18 @@
|
||||
const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795);
|
||||
const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4;
|
||||
|
||||
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
|
||||
aprsMap = MapUtils.init('aprsMap', {
|
||||
center: [initialLat, initialLon],
|
||||
zoom: initialZoom,
|
||||
minZoom: 2,
|
||||
maxZoom: 18,
|
||||
});
|
||||
if (!aprsMap) return;
|
||||
window.aprsMap = aprsMap;
|
||||
|
||||
// Zero-network fallback so mode switches never block on external tiles.
|
||||
const fallbackTiles = createAprsFallbackGridLayer().addTo(aprsMap);
|
||||
|
||||
// Upgrade tiles in background via Settings (with timeout fallback)
|
||||
if (typeof Settings !== 'undefined') {
|
||||
try {
|
||||
await Promise.race([
|
||||
Settings.init(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||
]);
|
||||
aprsMap.removeLayer(fallbackTiles);
|
||||
Settings.createTileLayer().addTo(aprsMap);
|
||||
Settings.registerMap(aprsMap);
|
||||
} catch (e) {
|
||||
console.warn('APRS: Settings init failed/timed out, using fallback tiles:', e);
|
||||
}
|
||||
}
|
||||
aprsMapOverlays = MapUtils.addTacticalOverlays(aprsMap, {
|
||||
scaleBar: true,
|
||||
});
|
||||
|
||||
// Add user marker if GPS position is already available
|
||||
if (gpsConnected && hasGpsLocation) {
|
||||
@@ -10243,6 +10309,7 @@
|
||||
} catch (_) {}
|
||||
aprsMap = null;
|
||||
window.aprsMap = null;
|
||||
aprsMapOverlays = null;
|
||||
}
|
||||
aprsMarkers = {};
|
||||
aprsUserMarker = null;
|
||||
@@ -10729,7 +10796,7 @@
|
||||
|
||||
// Keep log manageable
|
||||
while (logEl.children.length > 100) {
|
||||
logEl.removeChild(logEl.lastChild);
|
||||
logEl.lastElementChild.remove();
|
||||
}
|
||||
|
||||
// Update map if position data
|
||||
@@ -11302,6 +11369,7 @@
|
||||
|
||||
// Ground Track Map
|
||||
let groundTrackMap = null;
|
||||
let gpsMapOverlays = null;
|
||||
let groundTrackLine = null;
|
||||
let satMarker = null;
|
||||
let observerMarker = null;
|
||||
@@ -11311,47 +11379,24 @@
|
||||
const mapContainer = document.getElementById('groundTrackMap');
|
||||
if (!mapContainer || groundTrackMap) return;
|
||||
|
||||
groundTrackMap = L.map('groundTrackMap', {
|
||||
groundTrackMap = MapUtils.init('groundTrackMap', {
|
||||
center: [20, 0],
|
||||
zoom: 1,
|
||||
zoomControl: true,
|
||||
attributionControl: false
|
||||
attributionControl: false,
|
||||
});
|
||||
if (!groundTrackMap) return;
|
||||
window.groundTrackMap = groundTrackMap;
|
||||
|
||||
// Add fallback tiles immediately so the map is visible instantly
|
||||
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(groundTrackMap);
|
||||
gpsMapOverlays = MapUtils.addTacticalOverlays(groundTrackMap, {
|
||||
scaleBar: true,
|
||||
});
|
||||
|
||||
// Upgrade tiles in background via Settings (with timeout fallback)
|
||||
if (typeof Settings !== 'undefined') {
|
||||
try {
|
||||
await Promise.race([
|
||||
Settings.init(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
||||
]);
|
||||
groundTrackMap.removeLayer(fallbackTiles);
|
||||
Settings.createTileLayer().addTo(groundTrackMap);
|
||||
Settings.registerMap(groundTrackMap);
|
||||
} catch (e) {
|
||||
console.warn('Ground track: Settings init failed/timed out, using fallback tiles:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Add observer marker
|
||||
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
|
||||
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
|
||||
observerMarker = L.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
fillColor: '#ff6600',
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillOpacity: 1
|
||||
}).addTo(groundTrackMap).bindPopup('Observer Location');
|
||||
// Observer crosshair via MapUtils
|
||||
const obsLat = parseFloat(document.getElementById('obsLat')?.value) || 51.5;
|
||||
const obsLon = parseFloat(document.getElementById('obsLon')?.value) || -0.1;
|
||||
observerMarker = MapUtils._buildReticle([obsLat, obsLon]);
|
||||
observerMarker.addTo(groundTrackMap);
|
||||
}
|
||||
|
||||
function updateGroundTrack(pass) {
|
||||
@@ -12110,6 +12155,12 @@
|
||||
async function startTscmSweep() {
|
||||
const sweepType = document.getElementById('tscmSweepType').value;
|
||||
const baselineId = document.getElementById('tscmBaselineSelect').value || null;
|
||||
const customRanges = sweepType === 'custom' ? [{
|
||||
start: parseFloat(document.getElementById('tscmCustomStartMhz').value),
|
||||
end: parseFloat(document.getElementById('tscmCustomEndMhz').value),
|
||||
step: 0.1,
|
||||
name: `Custom ${document.getElementById('tscmCustomStartMhz').value}–${document.getElementById('tscmCustomEndMhz').value} MHz`
|
||||
}] : null;
|
||||
const wifiEnabled = document.getElementById('tscmWifiEnabled').checked;
|
||||
const btEnabled = document.getElementById('tscmBtEnabled').checked;
|
||||
const rfEnabled = document.getElementById('tscmRfEnabled').checked;
|
||||
@@ -12150,7 +12201,8 @@
|
||||
wifi_interface: wifiInterface,
|
||||
bt_interface: btInterface,
|
||||
sdr_device: sdrDevice ? parseInt(sdrDevice) : null,
|
||||
verbose_results: verboseResults
|
||||
verbose_results: verboseResults,
|
||||
custom_ranges: customRanges
|
||||
})
|
||||
});
|
||||
|
||||
@@ -12919,7 +12971,7 @@
|
||||
if (tscmSweepStartTime) {
|
||||
const elapsed = (Date.now() - tscmSweepStartTime) / 1000;
|
||||
const sweepType = document.getElementById('tscmSweepType')?.value || 'standard';
|
||||
const durations = { quick: 120, standard: 300, full: 900 };
|
||||
const durations = { quick: 120, standard: 300, full: 900, custom: 300 };
|
||||
const maxDuration = durations[sweepType] || 300;
|
||||
const progress = Math.min(95, (elapsed / maxDuration) * 100);
|
||||
updateTscmProgress({ progress: Math.round(progress), phase: 'Scanning' });
|
||||
@@ -16442,6 +16494,7 @@
|
||||
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
|
||||
<!-- Settings Manager -->
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
|
||||
<!-- Alerts + Recording -->
|
||||
<script src="{{ url_for('static', filename='js/core/alerts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/recordings.js') }}"></script>
|
||||
|
||||
@@ -18,6 +18,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>NMEA UDP Forward</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||
Forward NMEA 0183 sentences to an external app (e.g. OpenCPN). Leave host blank to disable.
|
||||
</p>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<div style="flex: 2;">
|
||||
<label style="font-size: 10px; color: var(--text-dim);">Host</label>
|
||||
<input type="text" id="aisUdpHost" placeholder="e.g. 192.168.1.10" style="width: 100%;">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 10px; color: var(--text-dim);">Port</label>
|
||||
<input type="number" id="aisUdpPort" value="10110" min="1" max="65535" style="width: 100%;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="aisStatusDisplay" class="info-text">
|
||||
@@ -110,11 +127,22 @@
|
||||
function startAisTracking() {
|
||||
const gain = document.getElementById('aisGainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
const udpHost = document.getElementById('aisUdpHost').value.trim();
|
||||
const udpPort = parseInt(document.getElementById('aisUdpPort').value) || 10110;
|
||||
|
||||
const body = {
|
||||
device, gain,
|
||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||
};
|
||||
if (udpHost) {
|
||||
body.udp_host = udpHost;
|
||||
body.udp_port = udpPort;
|
||||
}
|
||||
|
||||
fetch('/ais/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device, gain, bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false })
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="gain">Gain (dB, 0 = auto)</label>
|
||||
<input type="text" id="gain" value="0" placeholder="0-49 or 0 for auto">
|
||||
<input type="number" id="gain" value="0" min="0" max="50" step="0.5" placeholder="0 = auto">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="squelch">Squelch Level</label>
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
|
||||
// Map management
|
||||
let radiosondeMap = null;
|
||||
let radiosondeMapOverlays = null;
|
||||
let radiosondeMarkers = new Map();
|
||||
let radiosondeTracks = new Map();
|
||||
let radiosondeTrackPoints = new Map();
|
||||
@@ -295,16 +296,23 @@
|
||||
}
|
||||
const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
|
||||
|
||||
radiosondeMap = L.map('radiosondeMapContainer', {
|
||||
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
|
||||
zoom: hasLocation ? 7 : 4,
|
||||
zoomControl: true,
|
||||
});
|
||||
const observerLocation = hasLocation
|
||||
? { lat: radiosondeStationLocation.lat, lon: radiosondeStationLocation.lon }
|
||||
: { lat: 40, lon: -95 };
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap © CARTO',
|
||||
radiosondeMap = MapUtils.init('radiosondeMapContainer', {
|
||||
center: [observerLocation.lat, observerLocation.lon],
|
||||
zoom: hasLocation ? 7 : 4,
|
||||
minZoom: 2,
|
||||
maxZoom: 18,
|
||||
}).addTo(radiosondeMap);
|
||||
});
|
||||
if (radiosondeMap) {
|
||||
window.radiosondeMap = radiosondeMap;
|
||||
radiosondeMapOverlays = MapUtils.addTacticalOverlays(radiosondeMap, {
|
||||
hudPanels: { modeName: 'RADIOSONDE', getContactCount: () => 0 },
|
||||
scaleBar: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Add station marker if we have a location
|
||||
if (hasLocation) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="sensorGain">Gain (dB, 0 = auto)</label>
|
||||
<input type="text" id="sensorGain" value="0" placeholder="0-49 or 0 for auto">
|
||||
<input type="number" id="sensorGain" value="0" min="0" max="50" step="0.5" placeholder="0 = auto">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sensorPpm">PPM Correction</label>
|
||||
|
||||
@@ -6,14 +6,28 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label>Sweep Type</label>
|
||||
<select id="tscmSweepType">
|
||||
<select id="tscmSweepType" onchange="document.getElementById('tscmCustomRangeControls').style.display = this.value === 'custom' ? 'block' : 'none'">
|
||||
<option value="quick">Quick Scan (2 min)</option>
|
||||
<option value="standard" selected>Standard (5 min)</option>
|
||||
<option value="full">Full Sweep (15 min)</option>
|
||||
<option value="wireless_cameras">Wireless Cameras</option>
|
||||
<option value="body_worn">Body-Worn Devices</option>
|
||||
<option value="gps_trackers">GPS Trackers</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
<div id="tscmCustomRangeControls" style="display: none; margin-top: 8px;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 10px; color: var(--text-dim);">Start (MHz)</label>
|
||||
<input type="number" id="tscmCustomStartMhz" value="400" min="1" max="6000" step="1">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 10px; color: var(--text-dim);">End (MHz)</label>
|
||||
<input type="number" id="tscmCustomEndMhz" value="500" min="1" max="6000" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 3px;">Step: 100 kHz. Duration: ~5 min.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB)</label>
|
||||
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
|
||||
<input type="number" id="weatherSatGain" value="30" step="0.1" min="0" max="50">
|
||||
<p class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 3px;">Reduce if decoding fails on strong passes (ADC saturation).</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 6px;">
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
<option value="openstreetmap">OpenStreetMap</option>
|
||||
<option value="cartodb_light">CartoDB Positron</option>
|
||||
<option value="esri_world">ESRI World Imagery</option>
|
||||
<option value="stadia_dark">Stadia Alidade Dark</option>
|
||||
<option value="tactical">Stadia Tactical (minimal)</option>
|
||||
<option value="custom">Custom URL</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -90,6 +92,15 @@
|
||||
onchange="Settings.setCustomTileUrl(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row" id="stadiaKeyRow" style="display: none;">
|
||||
<div class="settings-label" style="width: 100%;">
|
||||
<span class="settings-label-text">Stadia API Key</span>
|
||||
<span class="settings-label-desc">Free at <a href="https://client.stadiamaps.com/signup/" target="_blank" style="color: var(--accent-cyan);">stadiamaps.com</a></span>
|
||||
<input type="text" id="stadiaKeyInput" class="settings-input"
|
||||
placeholder="your-api-key-here"
|
||||
onchange="Settings.setStadiaKey(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
|
||||
<script>
|
||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||
</script>
|
||||
@@ -766,6 +767,7 @@
|
||||
let passes = [];
|
||||
let selectedPass = null;
|
||||
let groundMap = null;
|
||||
let satMapOverlays = null;
|
||||
let satMarker = null;
|
||||
let trackLine = null;
|
||||
let observerMarker = null;
|
||||
@@ -1906,103 +1908,35 @@
|
||||
now.toISOString().substring(11, 19) + ' UTC';
|
||||
}
|
||||
|
||||
function createFallbackGridLayer() {
|
||||
const layer = L.gridLayer({
|
||||
tileSize: 256,
|
||||
updateWhenIdle: true,
|
||||
attribution: 'Local fallback grid'
|
||||
});
|
||||
layer.createTile = function(coords) {
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = 256;
|
||||
tile.height = 256;
|
||||
const ctx = tile.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#08121c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(256, 0);
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, 256);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 0);
|
||||
ctx.lineTo(128, 256);
|
||||
ctx.moveTo(0, 128);
|
||||
ctx.lineTo(256, 128);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||
|
||||
return tile;
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
async function upgradeGroundTilesFromSettings(fallbackTiles) {
|
||||
if (typeof Settings === 'undefined' || !groundMap) return;
|
||||
|
||||
try {
|
||||
await Settings.init();
|
||||
if (!groundMap) return;
|
||||
|
||||
const configuredLayer = Settings.createTileLayer();
|
||||
let tileLoaded = false;
|
||||
|
||||
configuredLayer.once('load', () => {
|
||||
tileLoaded = true;
|
||||
if (groundMap && fallbackTiles && groundMap.hasLayer(fallbackTiles)) {
|
||||
groundMap.removeLayer(fallbackTiles);
|
||||
}
|
||||
groundMap.invalidateSize(false);
|
||||
});
|
||||
|
||||
configuredLayer.on('tileerror', () => {
|
||||
if (!tileLoaded) {
|
||||
console.warn('Satellite tile layer failed to load, keeping fallback grid');
|
||||
}
|
||||
});
|
||||
|
||||
configuredLayer.addTo(groundMap);
|
||||
Settings.registerMap(groundMap);
|
||||
} catch (e) {
|
||||
console.warn('Satellite: Settings/tile upgrade failed, using fallback grid:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function initGroundMap() {
|
||||
const container = document.getElementById('groundMap');
|
||||
if (!container || container._leaflet_id) return;
|
||||
const mapContainer = document.getElementById('groundMap');
|
||||
if (!mapContainer || groundMap) return;
|
||||
|
||||
groundMap = L.map('groundMap', {
|
||||
groundMap = MapUtils.init('groundMap', {
|
||||
center: [20, 0],
|
||||
zoom: 2,
|
||||
zoom: 1,
|
||||
minZoom: 1,
|
||||
maxZoom: 10,
|
||||
worldCopyJump: true
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
if (!groundMap) return;
|
||||
window.groundMap = groundMap;
|
||||
|
||||
// Use a zero-network fallback so dashboard navigation stays fast even
|
||||
// when internet map providers are slow or unreachable.
|
||||
const fallbackTiles = createFallbackGridLayer().addTo(groundMap);
|
||||
satMapOverlays = MapUtils.addTacticalOverlays(groundMap, {
|
||||
hudPanels: {
|
||||
modeName: 'SAT TRACK',
|
||||
getContactCount: () => 0,
|
||||
},
|
||||
scaleBar: true,
|
||||
});
|
||||
|
||||
upgradeGroundTilesFromSettings(fallbackTiles);
|
||||
// Add observer marker via reticle
|
||||
const lat = parseFloat(document.getElementById('obsLat')?.value) || 51.5;
|
||||
const lon = parseFloat(document.getElementById('obsLon')?.value) || -0.1;
|
||||
observerMarker = MapUtils._buildReticle([lat, lon]);
|
||||
observerMarker.addTo(groundMap);
|
||||
|
||||
const lat = parseFloat(document.getElementById('obsLat')?.value);
|
||||
const lon = parseFloat(document.getElementById('obsLon')?.value);
|
||||
if (!Number.isNaN(lat) && !Number.isNaN(lon)) {
|
||||
groundMap.setView([lat, lon], 3);
|
||||
}
|
||||
requestAnimationFrame(() => groundMap?.invalidateSize(false));
|
||||
setTimeout(() => groundMap?.invalidateSize(false), 250);
|
||||
updateMapModeButtons();
|
||||
@@ -2450,16 +2384,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
const obsIcon = L.divIcon({
|
||||
className: 'obs-marker',
|
||||
html: `<div style="width: 12px; height: 12px; background: #ff9500; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 15px #ff9500;"></div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
});
|
||||
|
||||
observerMarker = L.marker([lat, lon], { icon: obsIcon })
|
||||
.addTo(groundMap)
|
||||
.bindPopup(`<b>${locationLabel}</b><br>${lat.toFixed(4)}°, ${lon.toFixed(4)}°`);
|
||||
observerMarker = MapUtils._buildReticle([lat, lon]);
|
||||
observerMarker.addTo(groundMap);
|
||||
observerMarker.bindPopup(`<b>${locationLabel}</b><br>${lat.toFixed(4)}°, ${lon.toFixed(4)}°`);
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
@@ -3163,6 +3090,7 @@
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
{% include 'partials/nav-utility-modals.html' %}
|
||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/ground_station_waterfall.js') }}"></script>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# tests/test_map_utils.py
|
||||
|
||||
|
||||
def test_map_utils_js_is_served(client):
|
||||
"""map-utils.js is accessible as a static file."""
|
||||
resp = client.get("/static/js/map-utils.js")
|
||||
assert resp.status_code == 200
|
||||
data = resp.data.decode()
|
||||
assert "MapUtils" in data
|
||||
assert "MapUtils.init" in data
|
||||
assert "addTacticalOverlays" in data
|
||||
|
||||
|
||||
def test_map_utils_css_is_served(client):
|
||||
"""map-utils.css is accessible as a static file."""
|
||||
resp = client.get("/static/css/core/map-utils.css")
|
||||
assert resp.status_code == 200
|
||||
data = resp.data.decode()
|
||||
assert "map-hud-panel" in data
|
||||
|
||||
|
||||
def test_adsb_dashboard_includes_map_utils(client):
|
||||
"""ADS-B dashboard loads map-utils.js and map-utils.css."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
resp = client.get("/adsb/dashboard")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
assert "map-utils.js" in html
|
||||
assert "map-utils.css" in html
|
||||
assert "MapUtils.init" in html
|
||||
|
||||
|
||||
def test_ais_dashboard_includes_map_utils(client):
|
||||
"""AIS dashboard loads map-utils.js."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
resp = client.get("/ais/dashboard")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
assert "map-utils.js" in html
|
||||
assert "MapUtils.init" in html
|
||||
|
||||
|
||||
def test_satellite_dashboard_includes_map_utils(client):
|
||||
"""Satellite dashboard loads map-utils.js."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
resp = client.get("/satellite/dashboard")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
assert "map-utils.js" in html
|
||||
assert "MapUtils.init" in html
|
||||
|
||||
|
||||
def test_index_includes_map_utils(client):
|
||||
"""Main SPA index.html loads map-utils.js and uses it for APRS and GPS maps."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
assert "map-utils.js" in html
|
||||
assert "MapUtils.init" in html
|
||||
|
||||
|
||||
def test_radiosonde_mode_uses_map_utils(client):
|
||||
"""Main SPA index (which includes radiosonde partial) uses MapUtils for radiosonde map."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
# Radiosonde map init in partial should call MapUtils.init, not bare L.tileLayer
|
||||
assert "radiosondeMap" in html
|
||||
# The bare cartocdn URL that was previously hardcoded should be gone
|
||||
assert "basemaps.cartocdn.com/dark_all" not in html or html.count("basemaps.cartocdn.com/dark_all") == 0
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Tests for nav group localStorage persistence (JS logic verified via structure check)."""
|
||||
|
||||
|
||||
def _logged_in_get(client, path):
|
||||
"""Make a GET request with a pre-seeded logged-in session."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
return client.get(path)
|
||||
|
||||
|
||||
def test_index_page_includes_nav_state_init(client):
|
||||
"""nav group init function must be present in the index page."""
|
||||
resp = _logged_in_get(client, "/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
assert "initNavGroupState" in html
|
||||
assert "localStorage" in html
|
||||
|
||||
|
||||
def test_nav_groups_have_data_group_attributes(client):
|
||||
"""Each nav group must have a data-group attribute for state keying."""
|
||||
resp = _logged_in_get(client, "/")
|
||||
html = resp.data.decode()
|
||||
for group in ["signals", "tracking", "space", "wireless", "intel", "system"]:
|
||||
assert f'data-group="{group}"' in html, f"Missing data-group={group}"
|
||||
@@ -0,0 +1,41 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client):
|
||||
"""Client with an authenticated session."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
return client
|
||||
|
||||
|
||||
def test_offline_settings_includes_stadia_key(auth_client):
|
||||
"""GET /offline/settings returns offline.stadia_key field."""
|
||||
resp = auth_client.get("/offline/settings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "offline.stadia_key" in data["settings"]
|
||||
|
||||
|
||||
def test_stadia_key_defaults_to_empty_string(auth_client):
|
||||
"""Stadia key defaults to empty string, not None."""
|
||||
# Reset to empty string first to ensure isolation between test runs.
|
||||
auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": ""})
|
||||
resp = auth_client.get("/offline/settings")
|
||||
data = resp.get_json()
|
||||
assert data["settings"]["offline.stadia_key"] == ""
|
||||
|
||||
|
||||
def test_stadia_key_can_be_saved(auth_client):
|
||||
"""POST /offline/settings saves offline.stadia_key."""
|
||||
resp = auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": "test-key-123"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["value"] == "test-key-123"
|
||||
|
||||
|
||||
def test_stadia_key_coerces_non_string(auth_client):
|
||||
"""POST /offline/settings coerces non-string stadia_key to string."""
|
||||
resp = auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": 42})
|
||||
# Should coerce to string '42' (type matches str default) — not 400
|
||||
assert resp.status_code == 200
|
||||
+841
-784
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -161,7 +161,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with Airspy.
|
||||
@@ -184,6 +186,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'biastee', '1'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
+5
-1
@@ -165,7 +165,9 @@ class CommandBuilder(ABC):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS decoder command for vessel tracking.
|
||||
@@ -175,6 +177,8 @@ class CommandBuilder(ABC):
|
||||
gain: Gain in dB (None for auto)
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
tcp_port: TCP port for JSON output server
|
||||
udp_host: Optional host to forward NMEA 0183 sentences via UDP
|
||||
udp_port: UDP port for NMEA forwarding (required if udp_host set)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
|
||||
+6
-1
@@ -161,7 +161,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with HackRF.
|
||||
@@ -184,6 +186,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'biastee', '1'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
@@ -140,7 +140,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with LimeSDR.
|
||||
@@ -161,6 +163,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-gr', 'tuner', str(int(gain))])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
+35
-1
@@ -75,6 +75,35 @@ def enable_bias_t_via_rtl_biast(device_index: int = 0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def disable_bias_t_via_rtl_biast(device_index: int = 0) -> bool:
|
||||
"""Disable bias-t power using rtl_biast (RTL-SDR Blog drivers).
|
||||
|
||||
Should be called when stopping an SDR mode that had bias-t enabled,
|
||||
since the hardware register persists after the device is closed.
|
||||
|
||||
Returns True if bias-t was disabled successfully.
|
||||
"""
|
||||
rtl_biast_path = get_tool_path('rtl_biast') or 'rtl_biast'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[rtl_biast_path, '-b', '0', '-d', str(device_index)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Bias-t disabled via rtl_biast on device {device_index}")
|
||||
return True
|
||||
logger.warning(f"rtl_biast failed (exit {result.returncode}): {result.stderr.strip()}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.warning("rtl_biast not found — bias-t may remain on after stop")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to disable bias-t via rtl_biast: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None:
|
||||
"""Detect the correct bias-t flag for the installed dump1090 variant.
|
||||
|
||||
@@ -281,7 +310,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking.
|
||||
@@ -308,6 +339,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'BIASTEE', 'on'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
@@ -139,7 +139,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
gain: float | None = None,
|
||||
bias_t: bool = False,
|
||||
tcp_port: int = 10110
|
||||
tcp_port: int = 10110,
|
||||
udp_host: str | None = None,
|
||||
udp_port: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build AIS-catcher command for AIS vessel tracking with SDRPlay.
|
||||
@@ -162,6 +164,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
||||
if bias_t:
|
||||
cmd.extend(['-gr', 'biastee', '1'])
|
||||
|
||||
if udp_host and udp_port:
|
||||
cmd.extend(['-u', udp_host, str(udp_port)])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_iq_capture_command(
|
||||
|
||||
+8
-3
@@ -93,11 +93,16 @@ def validate_rtl_tcp_port(port: Any) -> int:
|
||||
|
||||
|
||||
def validate_gain(gain: Any) -> float:
|
||||
"""Validate and return gain value."""
|
||||
"""Validate and return gain value.
|
||||
|
||||
Accepts 0 (auto/minimum) up to 102 dB to cover multi-stage SDRs
|
||||
(HackRF LNA+VGA = 40+62 = 102 dB max). RTL-SDR caps at 50 dB
|
||||
internally; values above 50 are only meaningful for HackRF/LimeSDR.
|
||||
"""
|
||||
try:
|
||||
gain_float = float(gain)
|
||||
if not 0 <= gain_float <= 50:
|
||||
raise ValueError(f"Gain must be between 0 and 50, got {gain_float}")
|
||||
if not 0 <= gain_float <= 102:
|
||||
raise ValueError(f"Gain must be between 0 and 102, got {gain_float}")
|
||||
return gain_float
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid gain: {gain}") from e
|
||||
|
||||
+103
-95
@@ -3,36 +3,44 @@
|
||||
Loads and caches station data from data/wefax_stations.json. Provides
|
||||
lookup by callsign and current-broadcast filtering based on UTC time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_stations_cache: list[dict] | None = None
|
||||
_stations_by_callsign: dict[str, dict] = {}
|
||||
_VALID_FREQUENCY_REFERENCES = {'auto', 'carrier', 'dial'}
|
||||
_VALID_FREQUENCY_REFERENCES = {"auto", "carrier", "dial"}
|
||||
WEFAX_USB_ALIGNMENT_OFFSET_KHZ = 1.9
|
||||
|
||||
_STATIONS_PATH = Path(__file__).resolve().parent.parent / 'data' / 'wefax_stations.json'
|
||||
|
||||
|
||||
def load_stations() -> list[dict]:
|
||||
"""Load all WeFax stations from JSON, caching on first call."""
|
||||
global _stations_cache, _stations_by_callsign
|
||||
|
||||
if _stations_cache is not None:
|
||||
return _stations_cache
|
||||
|
||||
with open(_STATIONS_PATH) as f:
|
||||
data = json.load(f)
|
||||
|
||||
_stations_cache = data.get('stations', [])
|
||||
_stations_by_callsign = {s['callsign']: s for s in _stations_cache}
|
||||
return _stations_cache
|
||||
|
||||
|
||||
_STATIONS_PATH = Path(__file__).resolve().parent.parent / "data" / "wefax_stations.json"
|
||||
|
||||
|
||||
def load_stations() -> list[dict]:
|
||||
"""Load all WeFax stations from JSON, caching on first call."""
|
||||
global _stations_cache, _stations_by_callsign
|
||||
|
||||
if _stations_cache is not None:
|
||||
return _stations_cache
|
||||
|
||||
if not _STATIONS_PATH.exists():
|
||||
log.warning("wefax_stations.json not found at %s", _STATIONS_PATH)
|
||||
_stations_cache = []
|
||||
return _stations_cache
|
||||
|
||||
with open(_STATIONS_PATH) as f:
|
||||
data = json.load(f)
|
||||
|
||||
_stations_cache = data.get("stations", [])
|
||||
_stations_by_callsign = {s["callsign"]: s for s in _stations_cache}
|
||||
return _stations_cache
|
||||
|
||||
|
||||
def get_station(callsign: str) -> dict | None:
|
||||
"""Get a single station by callsign."""
|
||||
load_stations()
|
||||
@@ -41,38 +49,38 @@ def get_station(callsign: str) -> dict | None:
|
||||
|
||||
def _normalize_frequency_reference(value: str | None) -> str:
|
||||
"""Normalize and validate frequency reference token."""
|
||||
reference = str(value or 'auto').strip().lower()
|
||||
reference = str(value or "auto").strip().lower()
|
||||
if reference not in _VALID_FREQUENCY_REFERENCES:
|
||||
choices = ', '.join(sorted(_VALID_FREQUENCY_REFERENCES))
|
||||
raise ValueError(f'frequency_reference must be one of: {choices}')
|
||||
choices = ", ".join(sorted(_VALID_FREQUENCY_REFERENCES))
|
||||
raise ValueError(f"frequency_reference must be one of: {choices}")
|
||||
return reference
|
||||
|
||||
|
||||
def _station_frequency_reference(station: dict, listed_frequency_khz: float) -> str:
|
||||
"""Infer whether a station frequency entry is carrier or already USB dial."""
|
||||
for entry in station.get('frequencies', []):
|
||||
for entry in station.get("frequencies", []):
|
||||
try:
|
||||
entry_khz = float(entry.get('khz'))
|
||||
entry_khz = float(entry.get("khz"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if abs(entry_khz - listed_frequency_khz) > 0.001:
|
||||
continue
|
||||
entry_ref = str(entry.get('reference', '')).strip().lower()
|
||||
if entry_ref in ('carrier', 'dial'):
|
||||
entry_ref = str(entry.get("reference", "")).strip().lower()
|
||||
if entry_ref in ("carrier", "dial"):
|
||||
return entry_ref
|
||||
|
||||
station_ref = str(station.get('frequency_reference', '')).strip().lower()
|
||||
if station_ref in ('carrier', 'dial'):
|
||||
station_ref = str(station.get("frequency_reference", "")).strip().lower()
|
||||
if station_ref in ("carrier", "dial"):
|
||||
return station_ref
|
||||
|
||||
# Most published marine WeFax channel lists are carrier frequencies.
|
||||
return 'carrier'
|
||||
return "carrier"
|
||||
|
||||
|
||||
def resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz: float,
|
||||
station_callsign: str = '',
|
||||
frequency_reference: str = 'auto',
|
||||
station_callsign: str = "",
|
||||
frequency_reference: str = "auto",
|
||||
) -> tuple[float, str, bool]:
|
||||
"""Resolve listed frequency to the actual USB dial frequency.
|
||||
|
||||
@@ -86,75 +94,75 @@ def resolve_tuning_frequency_khz(
|
||||
"""
|
||||
listed = float(listed_frequency_khz)
|
||||
if listed <= 0:
|
||||
raise ValueError('frequency_khz must be greater than zero')
|
||||
raise ValueError("frequency_khz must be greater than zero")
|
||||
|
||||
requested_ref = _normalize_frequency_reference(frequency_reference)
|
||||
resolved_ref = requested_ref
|
||||
|
||||
if requested_ref == 'auto':
|
||||
if requested_ref == "auto":
|
||||
station = get_station(station_callsign) if station_callsign else None
|
||||
if station:
|
||||
resolved_ref = _station_frequency_reference(station, listed)
|
||||
else:
|
||||
# For ad-hoc frequencies (no station metadata), treat input as dial.
|
||||
resolved_ref = 'dial'
|
||||
resolved_ref = "dial"
|
||||
|
||||
if resolved_ref == 'carrier':
|
||||
if resolved_ref == "carrier":
|
||||
tuned = round(listed - WEFAX_USB_ALIGNMENT_OFFSET_KHZ, 3)
|
||||
if tuned <= 0:
|
||||
raise ValueError('frequency_khz too low after USB alignment offset')
|
||||
raise ValueError("frequency_khz too low after USB alignment offset")
|
||||
return tuned, resolved_ref, True
|
||||
|
||||
return listed, resolved_ref, False
|
||||
|
||||
|
||||
def get_current_broadcasts(callsign: str) -> list[dict]:
|
||||
"""Return schedule entries closest to the current UTC time.
|
||||
|
||||
Returns up to 3 entries: the most recent past broadcast and the
|
||||
next two upcoming ones, annotated with ``minutes_until`` or
|
||||
``minutes_ago`` relative to now.
|
||||
"""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
|
||||
schedule = station.get('schedule', [])
|
||||
if not schedule:
|
||||
return []
|
||||
|
||||
# Convert schedule times to minutes-since-midnight for comparison
|
||||
entries: list[tuple[int, dict]] = []
|
||||
for entry in schedule:
|
||||
parts = entry['utc'].split(':')
|
||||
mins = int(parts[0]) * 60 + int(parts[1])
|
||||
entries.append((mins, entry))
|
||||
entries.sort(key=lambda x: x[0])
|
||||
|
||||
# Find closest entries relative to now
|
||||
results = []
|
||||
for mins, entry in entries:
|
||||
diff = mins - current_minutes
|
||||
# Wrap around midnight
|
||||
if diff < -720:
|
||||
diff += 1440
|
||||
elif diff > 720:
|
||||
diff -= 1440
|
||||
|
||||
annotated = dict(entry)
|
||||
if diff >= 0:
|
||||
annotated['minutes_until'] = diff
|
||||
else:
|
||||
annotated['minutes_ago'] = abs(diff)
|
||||
annotated['_sort_key'] = abs(diff)
|
||||
results.append(annotated)
|
||||
|
||||
results.sort(key=lambda x: x['_sort_key'])
|
||||
|
||||
# Return 3 nearest entries, clean up sort key
|
||||
for r in results:
|
||||
r.pop('_sort_key', None)
|
||||
return results[:3]
|
||||
|
||||
|
||||
def get_current_broadcasts(callsign: str) -> list[dict]:
|
||||
"""Return schedule entries closest to the current UTC time.
|
||||
|
||||
Returns up to 3 entries: the most recent past broadcast and the
|
||||
next two upcoming ones, annotated with ``minutes_until`` or
|
||||
``minutes_ago`` relative to now.
|
||||
"""
|
||||
station = get_station(callsign)
|
||||
if not station:
|
||||
return []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
|
||||
schedule = station.get("schedule", [])
|
||||
if not schedule:
|
||||
return []
|
||||
|
||||
# Convert schedule times to minutes-since-midnight for comparison
|
||||
entries: list[tuple[int, dict]] = []
|
||||
for entry in schedule:
|
||||
parts = entry["utc"].split(":")
|
||||
mins = int(parts[0]) * 60 + int(parts[1])
|
||||
entries.append((mins, entry))
|
||||
entries.sort(key=lambda x: x[0])
|
||||
|
||||
# Find closest entries relative to now
|
||||
results = []
|
||||
for mins, entry in entries:
|
||||
diff = mins - current_minutes
|
||||
# Wrap around midnight
|
||||
if diff < -720:
|
||||
diff += 1440
|
||||
elif diff > 720:
|
||||
diff -= 1440
|
||||
|
||||
annotated = dict(entry)
|
||||
if diff >= 0:
|
||||
annotated["minutes_until"] = diff
|
||||
else:
|
||||
annotated["minutes_ago"] = abs(diff)
|
||||
annotated["_sort_key"] = abs(diff)
|
||||
results.append(annotated)
|
||||
|
||||
results.sort(key=lambda x: x["_sort_key"])
|
||||
|
||||
# Return 3 nearest entries, clean up sort key
|
||||
for r in results:
|
||||
r.pop("_sort_key", None)
|
||||
return results[:3]
|
||||
|
||||
Reference in New Issue
Block a user