mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77cdd56641 | |||
| b26d94c967 | |||
| 98e01f4c5b | |||
| 32f245e6ef | |||
| bf50cb4acd | |||
| e778efa5b6 | |||
| 7d537998ca | |||
| daaf3d2158 | |||
| 2dfdcd39f1 | |||
| 5e568f59ba | |||
| ae5664dbb4 | |||
| e64d82ebb5 | |||
| c5fdf7f7e9 | |||
| a59d4ec603 | |||
| 2cdf156cd0 | |||
| 7c535d7ba8 | |||
| f7d8af493a | |||
| 46f076077b | |||
| 020126b6e0 | |||
| 99268e47b8 | |||
| 7940728b30 | |||
| c4d6d50687 | |||
| 52ab1b60a3 | |||
| 9641c43384 | |||
| 1f7e0881f3 | |||
| b2cc6b65ad | |||
| 28a779b91b | |||
| e397a69dae | |||
| 3554817f91 | |||
| 410225d54d | |||
| 4ba8a40af9 | |||
| 6523686aca | |||
| 2475e5dd5a | |||
| 8f6bfb4df1 | |||
| 36a1542176 | |||
| 71011dd67c | |||
| 173ddc9eac | |||
| 53699482e1 | |||
| e5c5afb158 | |||
| f2af2ad0b6 | |||
| 3f6f8a5695 | |||
| 0d9bb53722 | |||
| d84cd41896 | |||
| ed4b6ef897 | |||
| f50f5e2d44 | |||
| 71eaf1d22a | |||
| ba02f761c6 | |||
| 80bbdb2c09 | |||
| 6807ee6878 | |||
| 04d2e1a7bf | |||
| 07e45f508a | |||
| 2b9665c723 | |||
| c73d02f76c | |||
| 5048711acb | |||
| 62e53c5dfa | |||
| 333c5cf8d3 | |||
| d033a95b0e | |||
| 3b480eb183 | |||
| 8632e31c01 | |||
| 14e6305aa4 | |||
| e059be2d84 | |||
| 1a99a7213f | |||
| f9e8fa896d | |||
| 59713ffc22 | |||
| 681a498461 | |||
| e8b94b6efc | |||
| 5dda961dbb | |||
| a6ce5d5426 | |||
| 772b5d0973 | |||
| b707468cb6 | |||
| e33dff1ab9 | |||
| 58222b3474 | |||
| b78ca51db1 | |||
| 4a149525bd | |||
| dd1c6b8b62 | |||
| a982ff5885 | |||
| 7cf94cce14 | |||
| 1dc45a285d | |||
| c70c93c814 | |||
| f51682f929 | |||
| f12f4145ef | |||
| 6dce911172 | |||
| 8ae19beef6 | |||
| 3693b02cb9 | |||
| ac2f7ea032 | |||
| 24f12b1220 | |||
| 88db107691 | |||
| 7dfefb48e6 | |||
| 12af6e250e | |||
| 5ffd9e5fb3 | |||
| 7d78bb45d6 | |||
| 9a82328de2 | |||
| 51f8a6f65b | |||
| 99edea33e3 | |||
| f97782724e | |||
| e4df3eaecb | |||
| 16b95e4804 | |||
| f1a029262b | |||
| 51c10144c7 | |||
| b5ae7fe472 | |||
| b75d28f284 | |||
| 4a3a7127ca | |||
| bfff092657 | |||
| f2f17ac26e | |||
| 8c61af2863 | |||
| 34fb030af1 | |||
| 238ad7936a | |||
| b01598753d | |||
| 3fedff9d08 | |||
| 1fc80b05b1 | |||
| 0210791c69 | |||
| 592e97719b | |||
| ea80b5ebc3 | |||
| fe64dd9c93 | |||
| f0fb97512a | |||
| 6ea34a4c60 | |||
| 6572119360 | |||
| efb7d0ed20 | |||
| 5b9d81e3a8 | |||
| 71e5599300 | |||
| 6967a44620 | |||
| ab4745c70a | |||
| d2c00b4b2c | |||
| d45b8bc2fb | |||
| 2511227c4e | |||
| 5ee60c5259 | |||
| 7a4dbb8260 | |||
| 73b227c49b | |||
| bfbf06f5c5 | |||
| e5a0635418 | |||
| 2fce80677a | |||
| 56ebdd7670 | |||
| 4c37d39e07 | |||
| d1d44195c1 | |||
| 0dbcb175c0 | |||
| ea348b3360 | |||
| 36399cf4aa | |||
| 837090d150 | |||
| d01cb4b6f3 | |||
| 3aadaf1c86 | |||
| 6de443e833 | |||
| f4672cf0c7 | |||
| b66ac935b7 | |||
| 7d704c9d42 | |||
| ebc838fa9d | |||
| 1e5bc0054d | |||
| 43fb735e4e | |||
| 1dde2a008e | |||
| af2ab567ca | |||
| 6928b8a622 | |||
| 205f396942 | |||
| 89c7c2fb07 | |||
| b20b9838d0 | |||
| 2d65c4efbf | |||
| 34e1d25069 | |||
| 90d39f12c1 | |||
| bca7888077 | |||
| cbc6275307 | |||
| b26ce4f56f | |||
| 44428c2517 | |||
| a670103325 | |||
| a2bd0e27f9 | |||
| 7ca018fd7b | |||
| 607a2f28fa | |||
| a42ea35d8b | |||
| 123d38d295 | |||
| 35c874da52 | |||
| ad4a4db160 | |||
| 72d4fab25e | |||
| 7c4342e560 | |||
| 33959403f4 | |||
| f549957c0b | |||
| e5abeba11c | |||
| 8cf1b05042 | |||
| cfcdc8e85e | |||
| d240ae06e3 | |||
| d84237dbb4 | |||
| 7194422c0e | |||
| d20808fb35 | |||
| 51b332f4cf | |||
| a8f73f9a73 | |||
| 4798652ad5 | |||
| 080464de98 | |||
| 8caec74c5c | |||
| 511cecb311 | |||
| 0992d6578c | |||
| 3f1564817c | |||
| b62b97ab57 | |||
| 2eeea3b74d | |||
| f05a5197cd | |||
| 016d05f082 | |||
| 302a362885 | |||
| 81c05859fc | |||
| f1881fdf52 | |||
| d0731120f9 | |||
| 7677b12f74 | |||
| ddaf5aa64e | |||
| 2418ae2d8b | |||
| 0916b62bfe | |||
| 0b22393395 | |||
| 9fa492e20c | |||
| fa46483dd9 | |||
| 18b442eb21 | |||
| 5f34d20287 | |||
| 5905aa6415 | |||
| aaed831420 | |||
| 007a8d50c6 | |||
| 02ce4d5bb6 | |||
| 613258c3a2 | |||
| 4410aa2433 | |||
| 54ad3b9362 | |||
| 2cf2c6af2a | |||
| f5f3e766ad | |||
| fb8b6a01e8 | |||
| db0a26cd64 | |||
| 8b1ca5ab96 | |||
| cb0fb4f3be | |||
| 334146b799 | |||
| 63237b9534 | |||
| 595a2003d5 | |||
| 3afaa6e1ee | |||
| 5731631ebc | |||
| ac445184b6 | |||
| 981b103b90 | |||
| af7b29b6b0 | |||
| 0ff0df632b | |||
| 73e17e8509 | |||
| 317e0d7108 | |||
| dd37a0b5a7 | |||
| 28f172a643 | |||
| 96146a2e2c | |||
| e32942fb35 | |||
| a61d4331f0 | |||
| 62ee2252a3 | |||
| 6fd5098b89 | |||
| 6941e704cd | |||
| 985c8a155a | |||
| d0402f4746 | |||
| 6dc0936d6d | |||
| 38a10cb0de | |||
| badf587be6 | |||
| a995fceb8c | |||
| 2a9c98a83d | |||
| 4cf394f92e | |||
| e388baa464 | |||
| 5cae753e0d | |||
| 86625cf3ec | |||
| 98bb6ce10b | |||
| cbe7f591e3 | |||
| 0078d539de | |||
| e1b532d48a | |||
| f043baed9f | |||
| 8d8ee57cec | |||
| 4607c358ed | |||
| ed1461626b | |||
| ee9bd9bbb2 | |||
| 75da95b38a | |||
| 5896ebd5b7 | |||
| 9e7dfbda5a | |||
| dc84e933c1 | |||
| 3140f54419 | |||
| e9fdadbbd8 | |||
| 8d537a61ed | |||
| ddf23377c3 | |||
| c0138ed849 | |||
| b5115d4aa1 | |||
| 6b9c4ebebd |
@@ -42,7 +42,6 @@ tasks/
|
||||
instance/
|
||||
|
||||
# data/ is a Python package — only exclude non-code files
|
||||
data/*.json
|
||||
data/*.csv
|
||||
data/*.db
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Set permissions for GITHUB_TOKEN
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Step 1: Check out the repository code
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Step 2: Set up QEMU for multi-arch builds
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Step 3: Set up Docker Buildx for advanced features
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Step 4: Log in to GitHub Container Registry
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Step 5: Generate tags and labels from Git metadata
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
# Tag with branch name
|
||||
type=ref,event=branch
|
||||
# Tag with PR number
|
||||
type=ref,event=pr
|
||||
# Tag with semver from git tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
# Tag with short SHA
|
||||
type=sha,prefix=
|
||||
|
||||
# Step 6: Build and push the Docker image
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# Only push on main branch and tags, not PRs
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Enable build cache for faster builds
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -67,3 +67,8 @@ data/subghz/captures/
|
||||
|
||||
# Local utility scripts
|
||||
reset-sdr.*
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -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.
|
||||
+25
-6
@@ -126,6 +126,7 @@ RUN cd /tmp \
|
||||
&& rm -rf /tmp/slowrx
|
||||
|
||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||
# Split into compile (heavy, cached) and staging (light, safe to change) layers
|
||||
RUN cd /tmp \
|
||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||
&& cd SatDump \
|
||||
@@ -147,14 +148,29 @@ RUN cd /tmp \
|
||||
fi; \
|
||||
done; \
|
||||
fi \
|
||||
# Copy SatDump install artifacts to staging
|
||||
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
|
||||
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
|
||||
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
|
||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
|
||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
|
||||
&& rm -rf /tmp/SatDump
|
||||
|
||||
# Stage SatDump artifacts (separate layer so compile cache survives staging changes)
|
||||
# On arm64 cmake installs to /usr/{bin,lib,share}; on x86 to /usr/local/{bin,lib,share}
|
||||
RUN mkdir -p /staging/usr/local/share /staging/usr/local/lib/satdump/plugins \
|
||||
# Binary
|
||||
&& (cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null \
|
||||
|| cp -a /usr/bin/satdump /staging/usr/local/bin/) \
|
||||
# Core shared library
|
||||
&& (cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null \
|
||||
|| cp -a /usr/lib/libsatdump* /staging/usr/local/lib/) \
|
||||
# Plugins
|
||||
&& (cp -a /usr/local/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||
|| cp -a /usr/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||
|| true) \
|
||||
# Pipeline definitions and resources
|
||||
&& (cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null \
|
||||
|| cp -a /usr/share/satdump /staging/usr/local/share/) \
|
||||
# Verify
|
||||
&& test -x /staging/usr/local/bin/satdump \
|
||||
&& ls /staging/usr/local/share/satdump/pipelines/*.json >/dev/null 2>&1 \
|
||||
&& echo "SatDump staging OK: $(ls /staging/usr/local/share/satdump/pipelines/*.json | wc -l) pipeline files"
|
||||
|
||||
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||
RUN cd /tmp \
|
||||
@@ -219,6 +235,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpng16-16 \
|
||||
libtiff6 \
|
||||
libjemalloc2 \
|
||||
libfftw3-double3 \
|
||||
libfftw3-single3 \
|
||||
libvolk-bin \
|
||||
libnng1 \
|
||||
libzstd1 \
|
||||
@@ -254,6 +272,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
||||
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
||||
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
||||
COPY --from=builder /staging/usr/local/share/ /usr/local/share/
|
||||
COPY --from=builder /staging/opt/ /opt/
|
||||
|
||||
# Copy radiosonde Python dependencies installed during builder stage
|
||||
|
||||
@@ -55,6 +55,7 @@ Support the developer of this open-source project
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
- **Drone Intelligence** - Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring
|
||||
|
||||
---
|
||||
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -7,10 +7,30 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.26.11"
|
||||
VERSION = "2.26.13"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.26.13",
|
||||
"date": "March 2026",
|
||||
"highlights": [
|
||||
"Fix TSCM sweep module variable scoping and stale progress bar",
|
||||
"Fix 5GHz WiFi scanning failures in deep scan and band detection",
|
||||
"Fix ADS-B remote mode incorrectly stopping other SDR services",
|
||||
"Fix radiosonde false 'missing' report at end of setup",
|
||||
"Satellite tracker: TLE auto-refresh, polar plot fixes, pass calculation improvements",
|
||||
"Fix weather satellite handoff (remove defunct METEOR-M2)",
|
||||
"Add multi-arch Docker CI workflow (amd64 + arm64)",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.26.12",
|
||||
"date": "March 2026",
|
||||
"highlights": [
|
||||
"AIS and ADS-B dashboards now use configured observer position from .env",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.26.11",
|
||||
"date": "March 2026",
|
||||
@@ -439,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)
|
||||
|
||||
+18
-14
@@ -6,17 +6,15 @@
|
||||
# Basic usage (build locally):
|
||||
# docker compose --profile basic up -d --build
|
||||
#
|
||||
# Basic usage (pre-built image from registry):
|
||||
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
||||
#
|
||||
# With ADS-B history (Postgres):
|
||||
# docker compose --profile history up -d
|
||||
|
||||
services:
|
||||
intercept:
|
||||
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
# Always build and use the local image
|
||||
image: intercept:latest
|
||||
build: .
|
||||
pull_policy: never
|
||||
container_name: intercept
|
||||
ports:
|
||||
- "5050:5050"
|
||||
@@ -28,8 +26,12 @@ services:
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
# Persist decoded images and database across container rebuilds
|
||||
- ./data:/app/data
|
||||
# Persist runtime output directories across container rebuilds.
|
||||
# Mount subdirectories individually so Python modules in /app/data are not shadowed.
|
||||
- ./data/weather_sat:/app/data/weather_sat
|
||||
- ./data/radiosonde:/app/data/radiosonde
|
||||
- ./data/subghz:/app/data/subghz
|
||||
- ./data/adsb:/app/data/adsb
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
@@ -68,9 +70,10 @@ services:
|
||||
# ADS-B history with Postgres persistence
|
||||
# Enable with: docker compose --profile history up -d
|
||||
intercept-history:
|
||||
# Same image/build fallback pattern as above
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
# Always build and use the local image
|
||||
image: intercept:latest
|
||||
build: .
|
||||
pull_policy: never
|
||||
container_name: intercept-history
|
||||
profiles:
|
||||
- history
|
||||
@@ -86,7 +89,10 @@ services:
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data/weather_sat:/app/data/weather_sat
|
||||
- ./data/radiosonde:/app/data/radiosonde
|
||||
- ./data/subghz:/app/data/subghz
|
||||
- ./data/adsb:/app/data/adsb
|
||||
environment:
|
||||
- TZ=${TZ:-UTC}
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
@@ -105,6 +111,8 @@ services:
|
||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||
# Shared observer location across modules
|
||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||
# Disable login auth (set to true for local/dev use)
|
||||
- INTERCEPT_DISABLE_AUTH=${INTERCEPT_DISABLE_AUTH:-false}
|
||||
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||
@@ -135,7 +143,3 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Optional: Add volume for persistent SQLite database
|
||||
# volumes:
|
||||
# intercept-data:
|
||||
|
||||
@@ -354,6 +354,42 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
||||
- No cryptographic de-randomization
|
||||
- Passive screening only (no active probing by default)
|
||||
|
||||
## Drone Intelligence
|
||||
|
||||
Multi-vector UAV detection and identification system combining three complementary detection methods into unified contact tracking.
|
||||
|
||||
### Detection Vectors
|
||||
|
||||
- **Remote ID (WiFi/BLE)** — Parses ASTM F3411-22a broadcast frames from WiFi Beacon and BLE Advertisement packets. Extracts drone ID, operator ID, drone type, GPS position, altitude, speed, and emergency status. Mandatory for all drones >250g in the US/EU since 2023.
|
||||
- **RTL-SDR RF (433/868 MHz)** — Monitors ISM bands for control link and telemetry signals characteristic of consumer and FPV drones. Detects DJI OcuSync, FrSky, FlySky, and generic FSK/GFSK drone control protocols.
|
||||
- **HackRF (2.4/5.8 GHz)** — Wide-scan of video downlink and telemetry bands used by most consumer drones. Detects power above noise floor across 2.400–2.483 GHz and 5.725–5.875 GHz ISM bands.
|
||||
|
||||
### Contact Correlation
|
||||
|
||||
The `DroneCorrelator` merges raw observations from all three vectors into unified `DroneContact` objects:
|
||||
- **TTL-based store** — contacts expire after 120 seconds of no activity
|
||||
- **Multi-vector fusion** — a single contact can be seen on 1–3 vectors simultaneously
|
||||
- **Deduplication** — observations from the same vector within 5 seconds are collapsed
|
||||
|
||||
### Risk Scoring
|
||||
|
||||
| Level | Criteria |
|
||||
|-------|----------|
|
||||
| High | No Remote ID broadcast (non-compliant) or ASTM non-conformant frame |
|
||||
| Medium | Multiple detection vectors active, or RSSI delta >15 dB between vectors |
|
||||
| Low | Compliant Remote ID present, single detection vector |
|
||||
|
||||
### Live Map
|
||||
|
||||
Remote ID contacts with GPS position data are plotted on a Leaflet map. Markers show drone ID and last known coordinates. Map updates in real time via SSE.
|
||||
|
||||
### Requirements
|
||||
|
||||
- WiFi adapter capable of monitor mode (for BLE/WiFi Remote ID)
|
||||
- RTL-SDR dongle (for 433/868 MHz RF detection)
|
||||
- HackRF One (optional, for 2.4/5.8 GHz detection)
|
||||
- Python package: `opendroneid>=1.0`
|
||||
|
||||
## Meshtastic Mesh Networks
|
||||
|
||||
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||
|
||||
+173
@@ -446,6 +446,35 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||
|
||||
## Drone Intelligence
|
||||
|
||||
1. **Open Mode** - Select "Drone Intel" from the Intel group in the navigation bar
|
||||
2. **Configure Interfaces** - Enter your WiFi interface name (must support monitor mode) for Remote ID detection
|
||||
3. **Set RTL-SDR Index** - If you have multiple RTL-SDR devices, enter the device index (default: 0)
|
||||
4. **Start** - Click "Start Scan" to activate all available detection vectors simultaneously
|
||||
5. **Monitor Contacts** - Detected drone contacts appear in the contact list with ID, vectors, risk level, and last seen time
|
||||
6. **View Map** - Contacts with GPS data from Remote ID are plotted on the live map
|
||||
|
||||
### Detection Vectors
|
||||
|
||||
- **Remote ID (WiFi/BLE)** — Passive sniff of 802.11 beacon frames and BLE advertisements. Decodes ASTM F3411 payloads: drone GPS, operator ID, drone type, speed, altitude, and emergency status
|
||||
- **433/868 MHz RF** — RTL-SDR scans ISM bands for drone control link and telemetry RF signatures
|
||||
- **2.4/5.8 GHz** — HackRF (if present) sweeps video downlink bands for active drone transmissions
|
||||
|
||||
### Risk Levels
|
||||
|
||||
- **High** — Drone operating without Remote ID (non-compliant) or malformed ASTM frame. Warrants immediate attention.
|
||||
- **Medium** — Contact detected on multiple RF vectors, or significant RSSI difference between vectors (>15 dB). May indicate evasion or multi-radio platform.
|
||||
- **Low** — Compliant Remote ID broadcast, single detection vector. Standard consumer drone.
|
||||
|
||||
### Tips
|
||||
|
||||
- Remote ID is mandatory for drones >250g in the US (FAA) and EU (EU 2019/945) — absence of Remote ID is itself a significant indicator
|
||||
- WiFi adapter must support monitor mode; run `airmon-ng check kill` if other processes interfere
|
||||
- The contact map only shows drones that broadcast GPS coordinates via Remote ID
|
||||
- Contacts expire after 120 seconds of inactivity — the list shows only currently active drones
|
||||
- HackRF detection is passive (receive-only); no transmission occurs
|
||||
|
||||
## Spy Stations
|
||||
|
||||
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||
@@ -539,6 +568,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:
|
||||
|
||||
+6
-1
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">34</span>
|
||||
<span class="stat-value">35</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -202,6 +202,11 @@
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><line x1="8" y1="8" x2="9" y2="9"/><line x1="16" y1="8" x2="15" y2="9"/><line x1="8" y1="16" x2="9" y2="15"/><line x1="16" y1="16" x2="15" y2="15"/></svg></div>
|
||||
<h3>Drone Intelligence</h3>
|
||||
<p>Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
||||
<h3>Meshtastic</h3>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Meshcore Support — Design Spec
|
||||
|
||||
**Date:** 2026-05-10
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add a Meshcore mode to Intercept, providing full feature parity with the existing Meshtastic module. Meshcore is a LoRa mesh radio platform using a repeater-based routing model (dedicated infrastructure nodes relay; clients do not). It has an official Python library (`meshcore`, PyPI) and a published companion protocol.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Connection methods | USB serial + TCP + BLE | Maximum hardware flexibility |
|
||||
| Feature scope | Full parity with Meshtastic | Messages, node map, telemetry, traceroute, repeater management |
|
||||
| Async integration | Background asyncio thread | meshcore library is asyncio-based; this isolates it cleanly from Flask/gevent |
|
||||
| UI layout | Messages-first (mirror Meshtastic) | Sidebar: contacts/nodes. Center: message feed. Tabs: map, telemetry, repeaters |
|
||||
| BLE in Docker | Document limitation + proxy workaround | BLE unavailable in containers; meshcore-proxy bridges BLE → TCP |
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
utils/meshcore.py # MeshcoreClient singleton + dataclasses
|
||||
utils/meshcore_client.py # Thin async wrapper around meshcore library (lives in asyncio thread)
|
||||
routes/meshcore.py # Flask blueprint (/meshcore)
|
||||
static/js/modes/meshcore.js # Frontend IIFE module
|
||||
static/css/modes/meshcore.css # Scoped styles
|
||||
templates/partials/modes/meshcore.html # Sidebar partial
|
||||
tests/test_meshcore_client.py
|
||||
tests/test_meshcore_routes.py
|
||||
tests/test_meshcore_integration.py
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `routes/__init__.py` — import + `register_blueprint(meshcore_bp)`
|
||||
- `templates/index.html` — ~12 insertion points (CSS, partial, JS, validModes, modeGroups, etc.)
|
||||
- `requirements.txt` — add `meshcore>=1.0.0` (optional dep, graceful fallback if absent)
|
||||
- `.gitignore` — already has `.superpowers/` ✓
|
||||
|
||||
### Async Bridge Pattern
|
||||
|
||||
```
|
||||
meshcore library (asyncio event loop in daemon OS thread)
|
||||
→ event callbacks (_on_message, _on_node_update, _on_telemetry)
|
||||
→ asyncio.run_coroutine_threadsafe() → queue.Queue (thread-safe, max 500)
|
||||
→ /meshcore/stream SSE generator drains queue (30s keepalive timeout)
|
||||
→ Frontend EventSource routes by event type
|
||||
```
|
||||
|
||||
This is the same conceptual pattern as all other decoder integrations in Intercept (ADS-B socket reader, AIS-catcher output thread, rtl_433 stdout thread), just with an explicit asyncio loop instead of a subprocess thread.
|
||||
|
||||
## Data Model
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MeshcoreMessage:
|
||||
id: str
|
||||
sender_id: str
|
||||
recipient_id: str # node ID or broadcast address
|
||||
text: str
|
||||
timestamp: datetime
|
||||
hop_count: int
|
||||
snr: float | None
|
||||
is_direct: bool # DM vs broadcast
|
||||
pending: bool = False # optimistic send state
|
||||
|
||||
@dataclass
|
||||
class MeshcoreNode:
|
||||
node_id: str
|
||||
name: str
|
||||
is_repeater: bool # key Meshcore distinction — rendered differently on map
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
battery_pct: int | None
|
||||
last_seen: datetime
|
||||
snr: float | None
|
||||
hops_away: int | None
|
||||
|
||||
@dataclass
|
||||
class MeshcoreContact:
|
||||
node_id: str
|
||||
name: str
|
||||
public_key: str # Meshcore uses key-based addressing
|
||||
last_msg: datetime | None
|
||||
|
||||
@dataclass
|
||||
class MeshcoreTelemetry:
|
||||
node_id: str
|
||||
timestamp: datetime
|
||||
battery_pct: int | None
|
||||
voltage: float | None
|
||||
temperature: float | None
|
||||
humidity: float | None
|
||||
uptime_secs: int | None
|
||||
|
||||
@dataclass
|
||||
class MeshcoreTraceroute:
|
||||
origin_id: str
|
||||
destination_id: str
|
||||
hops: list[str]
|
||||
snr_per_hop: list[float]
|
||||
timestamp: datetime
|
||||
|
||||
@dataclass
|
||||
class SerialConfig:
|
||||
port: str | None = None # None = auto-discover
|
||||
baud: int = 115200
|
||||
|
||||
@dataclass
|
||||
class TCPConfig:
|
||||
host: str = "localhost"
|
||||
port: int = 5000 # meshcore-proxy default
|
||||
|
||||
@dataclass
|
||||
class BLEConfig:
|
||||
device_address: str | None = None # None = scan for first Meshcore device
|
||||
|
||||
ConnectionConfig = SerialConfig | TCPConfig | BLEConfig
|
||||
```
|
||||
|
||||
Connection state enum: `DISCONNECTED | CONNECTING | CONNECTED | ERROR`
|
||||
|
||||
## Connection Handling
|
||||
|
||||
### Serial
|
||||
Auto-discover: scan `/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/cu.usbserial*` and return list to frontend via `GET /meshcore/ports`. User can also specify path directly.
|
||||
|
||||
### TCP
|
||||
Direct connection to `host:port`. Primary use case: meshcore-proxy running on the host, exposing a local USB or BLE device over TCP for Docker deployments.
|
||||
|
||||
### BLE
|
||||
- Linux/RPi: meshcore library uses BlueZ (requires `bluetoothctl` accessible)
|
||||
- macOS: meshcore library uses CoreBluetooth
|
||||
- Docker: detect via presence of `/.dockerenv` or `INTERCEPT_DOCKER=1` env var; connect attempt fails fast with clear error directing user to meshcore-proxy
|
||||
|
||||
`GET /meshcore/ble/scan` returns: `[{"address": "AA:BB:CC:DD:EE:FF", "name": "MeshCore-Node1", "rssi": -72}]`
|
||||
|
||||
### Reconnect
|
||||
Exponential backoff: 3 retries at 5s, 15s, 45s (cap 60s). On final failure, pushes `status` SSE event with `state: "error"`. User can manually retry via `POST /meshcore/connect`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | /meshcore/status | Connection state + transport info |
|
||||
| POST | /meshcore/connect | Connect with SerialConfig, TCPConfig, or BLEConfig |
|
||||
| POST | /meshcore/disconnect | Disconnect and stop background thread |
|
||||
| GET | /meshcore/ports | List available serial ports |
|
||||
| GET | /meshcore/ble/scan | Scan for nearby Meshcore BLE devices |
|
||||
| GET | /meshcore/stream | SSE stream (messages, nodes, telemetry, status) |
|
||||
| GET | /meshcore/messages | Recent messages (last 500) |
|
||||
| POST | /meshcore/send | Send text message |
|
||||
| GET | /meshcore/nodes | All known nodes |
|
||||
| GET | /meshcore/contacts | Contact list |
|
||||
| POST | /meshcore/contacts | Add contact |
|
||||
| DELETE | /meshcore/contacts/`<id>` | Remove contact |
|
||||
| GET | /meshcore/telemetry/`<node_id>` | Telemetry history for node |
|
||||
| POST | /meshcore/traceroute | Request traceroute to node |
|
||||
| GET | /meshcore/repeaters | List repeater nodes |
|
||||
|
||||
## SSE Event Format
|
||||
|
||||
```json
|
||||
{"type": "message", "data": { ...MeshcoreMessage }}
|
||||
{"type": "node", "data": { ...MeshcoreNode }}
|
||||
{"type": "telemetry", "data": { ...MeshcoreTelemetry }}
|
||||
{"type": "traceroute", "data": { ...MeshcoreTraceroute }}
|
||||
{"type": "status", "data": {"state": "connected", "transport": "serial", "device": "/dev/ttyUSB0"}}
|
||||
```
|
||||
|
||||
Keepalive comment (`: keepalive`) sent every 30 seconds on idle.
|
||||
|
||||
## Frontend (meshcore.js)
|
||||
|
||||
IIFE pattern, same as all other Intercept JS modules. Key responsibilities:
|
||||
|
||||
- **SSE consumer** — `EventSource('/meshcore/stream')`, routes events by `type`
|
||||
- **Message feed** — append to scrolling list, optimistic pending state on send
|
||||
- **Sidebar** — contact list + node list; repeaters shown separately with triangle icon (vs circle for client nodes), matching Meshcore UI conventions
|
||||
- **Tabs** — Map (Leaflet, reuse existing map setup pattern), Telemetry (Chart.js, reuse existing chart helpers), Repeaters (dedicated table view)
|
||||
- **Connection panel** — transport selector (Serial / TCP / BLE), port/IP/address input, connect/disconnect button
|
||||
- **Traceroute modal** — hop diagram with SNR annotations, same visual style as Meshtastic traceroute
|
||||
|
||||
## Repeater Management
|
||||
|
||||
Meshcore repeaters are a first-class concept (unlike Meshtastic where all nodes relay). Design:
|
||||
|
||||
- Repeaters identified by `is_repeater: true` on `MeshcoreNode`
|
||||
- Rendered on map as orange triangles (client nodes = blue circles)
|
||||
- Dedicated "Repeaters" tab in the main panel showing: name, location, uptime, last seen, hop count
|
||||
- Repeater stats surfaced in telemetry if available (uptime_secs from `MeshcoreTelemetry`)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- meshcore library not installed → mode loads but shows "meshcore package required: `pip install meshcore`"
|
||||
- BLE in Docker → clear error: "BLE unavailable in Docker. Run meshcore-proxy on the host and connect via TCP."
|
||||
- Serial port not found → return available ports list in error response
|
||||
- Connection lost mid-session → automatic reconnect with backoff; SSE `status` event updates UI indicator
|
||||
- Send failure → SSE event clears pending state, shows error in message feed
|
||||
|
||||
## Testing
|
||||
|
||||
**`tests/test_meshcore_client.py`**
|
||||
- Connection state machine transitions
|
||||
- Reconnect backoff timing (mock asyncio loop)
|
||||
- Message parsing and queue feeding
|
||||
- Node/contact TTL expiry
|
||||
- BLE unavailability error (Docker scenario)
|
||||
|
||||
**`tests/test_meshcore_routes.py`**
|
||||
- All REST endpoints: correct JSON shape, status codes
|
||||
- `/meshcore/connect` with each connection config type
|
||||
- `/meshcore/send` with missing/invalid params → 400
|
||||
- SSE stream yields keepalive on empty queue
|
||||
- Input validation via `utils/validation.py`
|
||||
|
||||
**`tests/test_meshcore_integration.py`**
|
||||
- Mock meshcore library at boundary (same approach as mocking meshtastic SDK)
|
||||
- Full round-trip: connect → receive message event → appears in SSE stream
|
||||
- Traceroute request → hop structure correctly parsed
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
meshcore>=1.0.0 # optional — graceful degradation if absent
|
||||
```
|
||||
|
||||
No new frontend dependencies — Leaflet and Chart.js already present.
|
||||
|
||||
## Reference
|
||||
|
||||
- Meshcore Python library: https://github.com/meshcore-dev/meshcore_py
|
||||
- Companion protocol: https://docs.meshcore.io/companion_protocol/
|
||||
- meshcore-proxy (BLE/serial → TCP bridge): https://github.com/rgregg/meshcore-proxy
|
||||
- Existing Meshtastic implementation (reference): `utils/meshtastic.py`, `routes/meshtastic.py`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
"""Minimal Flask-SocketIO compatibility shim.
|
||||
|
||||
This is only intended to satisfy radiosonde_auto_rx's optional web UI
|
||||
dependency in environments where ``flask_socketio`` is not installed.
|
||||
It provides the small subset of the API that auto_rx imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SocketIO:
|
||||
"""Very small subset of Flask-SocketIO's SocketIO interface."""
|
||||
|
||||
def __init__(self, app, async_mode: str | None = None, *args, **kwargs):
|
||||
self.app = app
|
||||
self.async_mode = async_mode or "threading"
|
||||
self._handlers: dict[tuple[str, str | None], Callable[..., Any]] = {}
|
||||
|
||||
def on(self, event: str, namespace: str | None = None):
|
||||
"""Register an event handler decorator."""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
self._handlers[(event, namespace)] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def emit(self, event: str, data: Any = None, namespace: str | None = None, *args, **kwargs) -> None:
|
||||
"""No-op emit used when the real Socket.IO server is unavailable."""
|
||||
return None
|
||||
|
||||
def run(self, app=None, host: str = "127.0.0.1", port: int = 5000, *args, **kwargs) -> None:
|
||||
"""Fallback to Flask's built-in development server."""
|
||||
flask_app = app or self.app
|
||||
flask_app.run(
|
||||
host=host,
|
||||
port=port,
|
||||
threaded=True,
|
||||
use_reloader=False,
|
||||
)
|
||||
@@ -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
|
||||
+1
-1
@@ -3502,7 +3502,7 @@ class ModeManager:
|
||||
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
|
||||
satellites = load.tle_file(stations_url)
|
||||
|
||||
ts = load.timescale()
|
||||
ts = load.timescale(builtin=True)
|
||||
observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
|
||||
|
||||
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ pyserial>=3.5
|
||||
|
||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||
meshtastic>=2.0.0
|
||||
meshcore>=1.0.0
|
||||
|
||||
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||
scapy>=2.4.5
|
||||
@@ -45,6 +46,7 @@ cryptography>=41.0.0
|
||||
# mypy>=1.0.0
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
simple-websocket>=0.5.1
|
||||
websocket-client>=1.6.0
|
||||
|
||||
# System health monitoring (optional - graceful fallback if unavailable)
|
||||
|
||||
+8
-1
@@ -18,9 +18,12 @@ def register_blueprints(app):
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .controller import controller_bp
|
||||
from .correlation import correlation_bp
|
||||
from .drone import drone_bp
|
||||
from .dsc import dsc_bp
|
||||
from .gps import gps_bp
|
||||
from .ground_station import ground_station_bp
|
||||
from .listening_post import receiver_bp
|
||||
from .meshcore import meshcore_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .meteor_websocket import meteor_bp
|
||||
from .morse import morse_bp
|
||||
@@ -68,6 +71,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(meshcore_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
app.register_blueprint(controller_bp) # Remote agent controller
|
||||
@@ -89,6 +93,8 @@ def register_blueprints(app):
|
||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||
app.register_blueprint(system_bp) # System health monitoring
|
||||
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
||||
app.register_blueprint(ground_station_bp) # Ground station automation
|
||||
app.register_blueprint(drone_bp) # Drone intelligence / UAV detection
|
||||
|
||||
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||
if _csrf:
|
||||
@@ -97,5 +103,6 @@ def register_blueprints(app):
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||
|
||||
if hasattr(app_module, "tscm_queue") and hasattr(app_module, "tscm_lock"):
|
||||
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||
|
||||
+588
-455
File diff suppressed because it is too large
Load Diff
+42
-2
@@ -15,7 +15,7 @@ import time
|
||||
from flask import Blueprint, Response, jsonify, render_template, request
|
||||
|
||||
import app as app_module
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.constants import (
|
||||
AIS_RECONNECT_DELAY,
|
||||
AIS_SOCKET_TIMEOUT,
|
||||
@@ -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."""
|
||||
@@ -542,5 +580,7 @@ def ais_dashboard():
|
||||
return render_template(
|
||||
'ais_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
default_latitude=DEFAULT_LATITUDE,
|
||||
default_longitude=DEFAULT_LONGITUDE,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
+54
-36
@@ -40,14 +40,16 @@ from utils.trilateration import (
|
||||
estimate_location_from_observations,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
AGENT_HEALTH_TIMEOUT_SECONDS = 2.0
|
||||
AGENT_STATUS_TIMEOUT_SECONDS = 2.5
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
@@ -77,14 +79,18 @@ def get_agents():
|
||||
agents = list_agents(active_only=active_only)
|
||||
|
||||
# Optionally refresh status for each agent
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
if refresh:
|
||||
for agent in agents:
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
agent['healthy'] = client.health_check()
|
||||
except Exception:
|
||||
agent['healthy'] = False
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
if refresh:
|
||||
for agent in agents:
|
||||
try:
|
||||
client = AgentClient(
|
||||
agent['base_url'],
|
||||
api_key=agent.get('api_key'),
|
||||
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||
)
|
||||
agent['healthy'] = client.health_check()
|
||||
except Exception:
|
||||
agent['healthy'] = False
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -327,27 +333,36 @@ def check_all_agents_health():
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
client = create_client_from_agent(agent)
|
||||
|
||||
# Time the health check
|
||||
start_time = time.time()
|
||||
is_healthy = client.health_check()
|
||||
response_time = (time.time() - start_time) * 1000
|
||||
try:
|
||||
client = AgentClient(
|
||||
agent['base_url'],
|
||||
api_key=agent.get('api_key'),
|
||||
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
# Time the health check
|
||||
start_time = time.time()
|
||||
is_healthy = client.health_check()
|
||||
response_time = (time.time() - start_time) * 1000
|
||||
|
||||
result['healthy'] = is_healthy
|
||||
result['response_time_ms'] = round(response_time, 1)
|
||||
|
||||
if is_healthy:
|
||||
# Update last_seen in database
|
||||
update_agent(agent['id'], update_last_seen=True)
|
||||
|
||||
# Also fetch running modes
|
||||
try:
|
||||
status = client.get_status()
|
||||
result['running_modes'] = status.get('running_modes', [])
|
||||
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||
except Exception:
|
||||
# Update last_seen in database
|
||||
update_agent(agent['id'], update_last_seen=True)
|
||||
|
||||
# Also fetch running modes
|
||||
try:
|
||||
status_client = AgentClient(
|
||||
agent['base_url'],
|
||||
api_key=agent.get('api_key'),
|
||||
timeout=AGENT_STATUS_TIMEOUT_SECONDS,
|
||||
)
|
||||
status = status_client.get_status()
|
||||
result['running_modes'] = status.get('running_modes', [])
|
||||
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||
except Exception:
|
||||
pass # Status fetch is optional
|
||||
|
||||
except AgentConnectionError as e:
|
||||
@@ -673,6 +688,7 @@ def stream_all_agents():
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -709,11 +725,13 @@ def agent_management_page():
|
||||
return render_template('agents.html', version=VERSION)
|
||||
|
||||
|
||||
@controller_bp.route('/monitor')
|
||||
def network_monitor_page():
|
||||
"""Render the network monitor page for multi-agent aggregated view."""
|
||||
@controller_bp.route('/monitor')
|
||||
def network_monitor_page():
|
||||
"""Render the network monitor page for multi-agent aggregated view."""
|
||||
from flask import render_template
|
||||
return render_template('network_monitor.html')
|
||||
|
||||
from config import VERSION
|
||||
return render_template('network_monitor.html', version=VERSION)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
"""Drone intelligence routes — multi-vector UAV detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.drone.correlator import DroneCorrelator
|
||||
from utils.drone.remote_id import RemoteIDScanner
|
||||
from utils.drone.rf_detector import RFDetector
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_device_index
|
||||
|
||||
logger = logging.getLogger("intercept.drone")
|
||||
|
||||
drone_bp = Blueprint("drone", __name__, url_prefix="/drone")
|
||||
|
||||
_correlator: DroneCorrelator | None = None
|
||||
_remote_id_scanner: RemoteIDScanner | None = None
|
||||
_rf_detector: RFDetector | None = None
|
||||
_obs_queue: queue.Queue | None = None # raw observations from scanners/detectors
|
||||
_relay_thread: threading.Thread | None = None
|
||||
_drone_running = False
|
||||
_drone_lock = threading.Lock()
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
def _relay_observations() -> None:
|
||||
"""Read raw observations from _obs_queue and feed them into the correlator."""
|
||||
while True:
|
||||
obs = _obs_queue.get()
|
||||
if obs is _SENTINEL:
|
||||
break
|
||||
if _correlator is not None:
|
||||
_correlator.process(obs)
|
||||
|
||||
|
||||
def _ensure_workers() -> None:
|
||||
global _correlator, _remote_id_scanner, _rf_detector, _obs_queue, _relay_thread
|
||||
if _obs_queue is None:
|
||||
_obs_queue = queue.Queue(maxsize=512)
|
||||
if _correlator is None:
|
||||
_correlator = DroneCorrelator(output_queue=app_module.drone_queue)
|
||||
if _remote_id_scanner is None:
|
||||
_remote_id_scanner = RemoteIDScanner(output_queue=_obs_queue)
|
||||
if _rf_detector is None:
|
||||
_rf_detector = RFDetector(output_queue=_obs_queue)
|
||||
if _relay_thread is None or not _relay_thread.is_alive():
|
||||
_relay_thread = threading.Thread(target=_relay_observations, daemon=True)
|
||||
_relay_thread.start()
|
||||
|
||||
|
||||
@drone_bp.route("/devices")
|
||||
def devices():
|
||||
"""Return available WiFi interfaces and SDR devices for drone detection."""
|
||||
result: dict = {"wifi_interfaces": [], "sdr_devices": []}
|
||||
|
||||
# WiFi interfaces via iw/iwconfig
|
||||
if platform.system() == "Darwin":
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["networksetup", "-listallhardwareports"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
).stdout
|
||||
lines = out.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "Wi-Fi" in line or "AirPort" in line:
|
||||
port = line.replace("Hardware Port:", "").strip()
|
||||
for j in range(i + 1, min(i + 3, len(lines))):
|
||||
if "Device:" in lines[j]:
|
||||
dev = lines[j].split("Device:")[1].strip()
|
||||
result["wifi_interfaces"].append(
|
||||
{
|
||||
"name": dev,
|
||||
"display_name": f"{port} ({dev})",
|
||||
"type": "internal",
|
||||
"monitor_capable": False,
|
||||
}
|
||||
)
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
out = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5).stdout
|
||||
current: str | None = None
|
||||
for line in out.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("Interface"):
|
||||
current = line.split()[1]
|
||||
elif current and "type" in line:
|
||||
iface_type = line.split()[-1]
|
||||
result["wifi_interfaces"].append(
|
||||
{
|
||||
"name": current,
|
||||
"display_name": f"{current} ({iface_type})",
|
||||
"type": iface_type,
|
||||
"monitor_capable": True,
|
||||
}
|
||||
)
|
||||
current = None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
try:
|
||||
out = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5).stdout
|
||||
for line in out.split("\n"):
|
||||
if "IEEE 802.11" in line:
|
||||
iface = line.split()[0]
|
||||
result["wifi_interfaces"].append(
|
||||
{
|
||||
"name": iface,
|
||||
"display_name": f"{iface} (managed)",
|
||||
"type": "managed",
|
||||
"monitor_capable": True,
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# SDR devices
|
||||
try:
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
for sdr in SDRFactory.detect_devices():
|
||||
sdr_type = sdr.sdr_type.value if hasattr(sdr.sdr_type, "value") else str(sdr.sdr_type)
|
||||
display = sdr.name
|
||||
if sdr.serial and sdr.serial not in ("N/A", "Unknown"):
|
||||
display = f"{sdr.name} (SN: {sdr.serial[-8:]})"
|
||||
result["sdr_devices"].append(
|
||||
{"index": sdr.index, "name": sdr.name, "display_name": display, "type": sdr_type}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
running_as_root = os.geteuid() == 0
|
||||
warnings = []
|
||||
if not running_as_root:
|
||||
warnings.append(
|
||||
{
|
||||
"type": "privileges",
|
||||
"message": "Not running as root — WiFi monitor mode may be unavailable.",
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"devices": result,
|
||||
"running_as_root": running_as_root,
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@drone_bp.route("/status")
|
||||
def status():
|
||||
vectors = []
|
||||
if _remote_id_scanner and _remote_id_scanner.running:
|
||||
vectors.append("REMOTE_ID")
|
||||
if _rf_detector and _rf_detector.running:
|
||||
vectors.append("RF")
|
||||
return jsonify(
|
||||
{
|
||||
"running": _drone_running,
|
||||
"vectors": vectors,
|
||||
"contact_count": len(_correlator.get_all()) if _correlator else 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@drone_bp.route("/contacts")
|
||||
def contacts():
|
||||
if not _correlator:
|
||||
return jsonify([])
|
||||
return jsonify(_correlator.get_all())
|
||||
|
||||
|
||||
@drone_bp.route("/start", methods=["POST"])
|
||||
def start():
|
||||
global _drone_running
|
||||
body = request.json or {}
|
||||
wifi_iface = body.get("wifi_iface") or None
|
||||
try:
|
||||
rtl_index = validate_device_index(body.get("rtl_sdr_index", 0))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
use_hackrf = bool(body.get("use_hackrf", True))
|
||||
|
||||
with _drone_lock:
|
||||
_ensure_workers()
|
||||
if not _drone_running:
|
||||
if _remote_id_scanner:
|
||||
_remote_id_scanner.start(wifi_iface=wifi_iface)
|
||||
if _rf_detector:
|
||||
_rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf)
|
||||
_drone_running = True
|
||||
logger.info("Drone detection started")
|
||||
|
||||
return jsonify({"status": "ok", "running": True})
|
||||
|
||||
|
||||
@drone_bp.route("/stop", methods=["POST"])
|
||||
def stop():
|
||||
global _drone_running
|
||||
with _drone_lock:
|
||||
if _remote_id_scanner:
|
||||
_remote_id_scanner.stop()
|
||||
if _rf_detector:
|
||||
_rf_detector.stop()
|
||||
if _obs_queue is not None:
|
||||
_obs_queue.put_nowait(_SENTINEL)
|
||||
_drone_running = False
|
||||
logger.info("Drone detection stopped")
|
||||
return jsonify({"status": "ok", "running": False})
|
||||
|
||||
|
||||
@drone_bp.route("/stream")
|
||||
def stream():
|
||||
return Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.drone_queue,
|
||||
channel_key="drone",
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Ground Station REST API + SSE + WebSocket endpoints.
|
||||
|
||||
Phases implemented here:
|
||||
1 — Profile CRUD, scheduler control, observation history, SSE stream
|
||||
3 — SigMF recording browser (list / download / delete)
|
||||
5 — /ws/satellite_waterfall WebSocket
|
||||
6 — Rotator config / status / point / park endpoints
|
||||
"""
|
||||
|
||||
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.sse import sse_stream_fanout
|
||||
|
||||
logger = get_logger('intercept.ground_station.routes')
|
||||
|
||||
ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_station')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_scheduler():
|
||||
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||
return get_ground_station_scheduler()
|
||||
|
||||
|
||||
def _get_queue():
|
||||
import app as _app
|
||||
return getattr(_app, 'ground_station_queue', None) or queue.Queue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Observation Profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles', methods=['GET'])
|
||||
def list_profiles():
|
||||
from utils.ground_station.observation_profile import list_profiles as _list
|
||||
return jsonify([p.to_dict() for p in _list()])
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['GET'])
|
||||
def get_profile(norad_id: int):
|
||||
from utils.ground_station.observation_profile import get_profile as _get
|
||||
p = _get(norad_id)
|
||||
if not p:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
return jsonify(p.to_dict())
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles', methods=['POST'])
|
||||
def create_profile():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
_validate_profile(data)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
from utils.ground_station.observation_profile import (
|
||||
ObservationProfile,
|
||||
legacy_decoder_to_tasks,
|
||||
normalize_tasks,
|
||||
save_profile,
|
||||
tasks_to_legacy_decoder,
|
||||
)
|
||||
tasks = normalize_tasks(data.get('tasks'))
|
||||
if not tasks:
|
||||
tasks = legacy_decoder_to_tasks(
|
||||
str(data.get('decoder_type', 'fm')),
|
||||
bool(data.get('record_iq', False)),
|
||||
)
|
||||
profile = ObservationProfile(
|
||||
norad_id=int(data['norad_id']),
|
||||
name=str(data['name']),
|
||||
frequency_mhz=float(data['frequency_mhz']),
|
||||
decoder_type=tasks_to_legacy_decoder(tasks),
|
||||
gain=float(data.get('gain', 40.0)),
|
||||
bandwidth_hz=int(data.get('bandwidth_hz', 200_000)),
|
||||
min_elevation=float(data.get('min_elevation', 10.0)),
|
||||
enabled=bool(data.get('enabled', True)),
|
||||
record_iq=bool(data.get('record_iq', False)) or ('record_iq' in tasks),
|
||||
iq_sample_rate=int(data.get('iq_sample_rate', 2_400_000)),
|
||||
tasks=tasks,
|
||||
)
|
||||
saved = save_profile(profile)
|
||||
return jsonify(saved.to_dict()), 201
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['PUT'])
|
||||
def update_profile(norad_id: int):
|
||||
data = request.get_json(force=True) or {}
|
||||
from utils.ground_station.observation_profile import (
|
||||
get_profile as _get,
|
||||
)
|
||||
from utils.ground_station.observation_profile import (
|
||||
legacy_decoder_to_tasks,
|
||||
normalize_tasks,
|
||||
save_profile,
|
||||
tasks_to_legacy_decoder,
|
||||
)
|
||||
existing = _get(norad_id)
|
||||
if not existing:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
|
||||
# Apply updates
|
||||
for field, cast in [
|
||||
('name', str), ('frequency_mhz', float), ('decoder_type', str),
|
||||
('gain', float), ('bandwidth_hz', int), ('min_elevation', float),
|
||||
]:
|
||||
if field in data:
|
||||
setattr(existing, field, cast(data[field]))
|
||||
for field in ('enabled', 'record_iq'):
|
||||
if field in data:
|
||||
setattr(existing, field, bool(data[field]))
|
||||
if 'iq_sample_rate' in data:
|
||||
existing.iq_sample_rate = int(data['iq_sample_rate'])
|
||||
if 'tasks' in data:
|
||||
existing.tasks = normalize_tasks(data['tasks'])
|
||||
elif 'decoder_type' in data:
|
||||
existing.tasks = legacy_decoder_to_tasks(
|
||||
str(data.get('decoder_type', existing.decoder_type)),
|
||||
bool(data.get('record_iq', existing.record_iq)),
|
||||
)
|
||||
|
||||
existing.decoder_type = tasks_to_legacy_decoder(existing.tasks)
|
||||
existing.record_iq = bool(existing.record_iq) or ('record_iq' in existing.tasks)
|
||||
|
||||
saved = save_profile(existing)
|
||||
return jsonify(saved.to_dict())
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['DELETE'])
|
||||
def delete_profile(norad_id: int):
|
||||
from utils.ground_station.observation_profile import delete_profile as _del
|
||||
ok = _del(norad_id)
|
||||
if not ok:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
return jsonify({'status': 'deleted', 'norad_id': norad_id})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Scheduler control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/status', methods=['GET'])
|
||||
def scheduler_status():
|
||||
return jsonify(_get_scheduler().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/enable', methods=['POST'])
|
||||
def scheduler_enable():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
lat = float(data.get('lat', 0.0))
|
||||
lon = float(data.get('lon', 0.0))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr'))
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
status = _get_scheduler().enable(lat=lat, lon=lon, device=device, sdr_type=sdr_type)
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/disable', methods=['POST'])
|
||||
def scheduler_disable():
|
||||
return jsonify(_get_scheduler().disable())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/observations', methods=['GET'])
|
||||
def get_observations():
|
||||
return jsonify(_get_scheduler().get_scheduled_observations())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/trigger/<int:norad_id>', methods=['POST'])
|
||||
def trigger_manual(norad_id: int):
|
||||
ok, msg = _get_scheduler().trigger_manual(norad_id)
|
||||
if not ok:
|
||||
return jsonify({'error': msg}), 400
|
||||
return jsonify({'status': 'started', 'message': msg})
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/stop', methods=['POST'])
|
||||
def stop_active():
|
||||
return jsonify(_get_scheduler().stop_active())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Observation history (from DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/observations', methods=['GET'])
|
||||
def observation_history():
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'''SELECT * FROM ground_station_observations
|
||||
ORDER BY created_at DESC LIMIT ?''',
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch observation history: {e}")
|
||||
return jsonify([])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — SSE stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/stream')
|
||||
def sse_stream():
|
||||
gs_queue = _get_queue()
|
||||
return Response(
|
||||
sse_stream_fanout(gs_queue, 'ground_station'),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — SigMF recording browser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings', methods=['GET'])
|
||||
def list_recordings():
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100'
|
||||
).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch recordings: {e}")
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['GET'])
|
||||
def get_recording(rec_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM sigmf_recordings WHERE id=?', (rec_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return jsonify(dict(row))
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['DELETE'])
|
||||
def delete_recording(rec_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||
(rec_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
# Remove files
|
||||
for path_col in ('sigmf_data_path', 'sigmf_meta_path'):
|
||||
p = Path(row[path_col])
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
conn.execute('DELETE FROM sigmf_recordings WHERE id=?', (rec_id,))
|
||||
return jsonify({'status': 'deleted', 'id': rec_id})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>/download/<file_type>')
|
||||
def download_recording(rec_id: int, file_type: str):
|
||||
if file_type not in ('data', 'meta'):
|
||||
return jsonify({'error': 'file_type must be data or meta'}), 400
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||
(rec_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
|
||||
col = 'sigmf_data_path' if file_type == 'data' else 'sigmf_meta_path'
|
||||
p = Path(row[col])
|
||||
if not p.exists():
|
||||
return jsonify({'error': 'File not found on disk'}), 404
|
||||
|
||||
mimetype = 'application/octet-stream' if file_type == 'data' else 'application/json'
|
||||
return send_file(p, mimetype=mimetype, as_attachment=True, download_name=p.name)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/outputs', methods=['GET'])
|
||||
def list_outputs():
|
||||
try:
|
||||
query = '''
|
||||
SELECT * FROM ground_station_outputs
|
||||
WHERE (? IS NULL OR norad_id = ?)
|
||||
AND (? IS NULL OR observation_id = ?)
|
||||
AND (? IS NULL OR output_type = ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
'''
|
||||
norad_id = request.args.get('norad_id', type=int)
|
||||
observation_id = request.args.get('observation_id', type=int)
|
||||
output_type = request.args.get('type')
|
||||
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
query,
|
||||
(
|
||||
norad_id, norad_id,
|
||||
observation_id, observation_id,
|
||||
output_type, output_type,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
metadata_raw = item.get('metadata_json')
|
||||
if metadata_raw:
|
||||
try:
|
||||
item['metadata'] = json.loads(metadata_raw)
|
||||
except json.JSONDecodeError:
|
||||
item['metadata'] = {}
|
||||
else:
|
||||
item['metadata'] = {}
|
||||
item.pop('metadata_json', None)
|
||||
results.append(item)
|
||||
return jsonify(results)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/outputs/<int:output_id>/download', methods=['GET'])
|
||||
def download_output(output_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT file_path FROM ground_station_outputs WHERE id=?',
|
||||
(output_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
p = Path(row['file_path'])
|
||||
if not p.exists():
|
||||
return jsonify({'error': 'File not found on disk'}), 404
|
||||
return send_file(p, as_attachment=True, download_name=p.name)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/decode-jobs', methods=['GET'])
|
||||
def list_decode_jobs():
|
||||
try:
|
||||
query = '''
|
||||
SELECT * FROM ground_station_decode_jobs
|
||||
WHERE (? IS NULL OR norad_id = ?)
|
||||
AND (? IS NULL OR observation_id = ?)
|
||||
AND (? IS NULL OR backend = ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
'''
|
||||
norad_id = request.args.get('norad_id', type=int)
|
||||
observation_id = request.args.get('observation_id', type=int)
|
||||
backend = request.args.get('backend')
|
||||
limit = min(request.args.get('limit', 20, type=int) or 20, 200)
|
||||
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
query,
|
||||
(
|
||||
norad_id, norad_id,
|
||||
observation_id, observation_id,
|
||||
backend, backend,
|
||||
limit,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
details_raw = item.get('details_json')
|
||||
if details_raw:
|
||||
try:
|
||||
item['details'] = json.loads(details_raw)
|
||||
except json.JSONDecodeError:
|
||||
item['details'] = {}
|
||||
else:
|
||||
item['details'] = {}
|
||||
item.pop('details_json', None)
|
||||
results.append(item)
|
||||
return jsonify(results)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5 — Live waterfall WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def init_ground_station_websocket(app) -> None:
|
||||
"""Register the /ws/satellite_waterfall WebSocket endpoint."""
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError:
|
||||
logger.warning("flask-sock not installed — satellite waterfall WebSocket disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/satellite_waterfall')
|
||||
def satellite_waterfall_ws(ws):
|
||||
"""Stream binary waterfall frames from the active ground station IQ bus."""
|
||||
scheduler = _get_scheduler()
|
||||
wf_queue = scheduler.waterfall_queue
|
||||
|
||||
from utils.sse import subscribe_fanout_queue
|
||||
sub_queue, unsubscribe = subscribe_fanout_queue(
|
||||
source_queue=wf_queue,
|
||||
channel_key='gs_waterfall',
|
||||
subscriber_queue_size=120,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
frame = sub_queue.get(timeout=1.0)
|
||||
try:
|
||||
ws.send(frame)
|
||||
except Exception:
|
||||
break
|
||||
except queue.Empty:
|
||||
if not ws.connected:
|
||||
break
|
||||
finally:
|
||||
unsubscribe()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 6 — Rotator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/status', methods=['GET'])
|
||||
def rotator_status():
|
||||
from utils.rotator import get_rotator
|
||||
return jsonify(get_rotator().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/config', methods=['POST'])
|
||||
def rotator_config():
|
||||
data = request.get_json(force=True) or {}
|
||||
host = str(data.get('host', '127.0.0.1'))
|
||||
port = int(data.get('port', 4533))
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().connect(host, port)
|
||||
if not ok:
|
||||
return jsonify({'error': f'Could not connect to rotctld at {host}:{port}'}), 503
|
||||
return jsonify(get_rotator().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/point', methods=['POST'])
|
||||
def rotator_point():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
az = float(data['az'])
|
||||
el = float(data['el'])
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
return jsonify({'error': f'az and el required: {e}'}), 400
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().point_to(az, el)
|
||||
if not ok:
|
||||
return jsonify({'error': 'Rotator command failed'}), 503
|
||||
return jsonify({'status': 'ok', 'az': az, 'el': el})
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/park', methods=['POST'])
|
||||
def rotator_park():
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().park()
|
||||
if not ok:
|
||||
return jsonify({'error': 'Rotator park failed'}), 503
|
||||
return jsonify({'status': 'parked'})
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/disconnect', methods=['POST'])
|
||||
def rotator_disconnect():
|
||||
from utils.rotator import get_rotator
|
||||
get_rotator().disconnect()
|
||||
return jsonify({'status': 'disconnected'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_profile(data: dict) -> None:
|
||||
if 'norad_id' not in data:
|
||||
raise ValueError("norad_id is required")
|
||||
if 'name' not in data:
|
||||
raise ValueError("name is required")
|
||||
if 'frequency_mhz' not in data:
|
||||
raise ValueError("frequency_mhz is required")
|
||||
try:
|
||||
norad_id = int(data['norad_id'])
|
||||
if norad_id <= 0:
|
||||
raise ValueError("norad_id must be positive")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("norad_id must be a positive integer")
|
||||
try:
|
||||
freq = float(data['frequency_mhz'])
|
||||
if not (0.1 <= freq <= 3000.0):
|
||||
raise ValueError("frequency_mhz must be between 0.1 and 3000")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("frequency_mhz must be a number between 0.1 and 3000")
|
||||
from utils.ground_station.observation_profile import VALID_TASK_TYPES
|
||||
|
||||
valid_decoders = {'fm', 'afsk', 'gmsk', 'bpsk', 'iq_only'}
|
||||
if 'tasks' in data:
|
||||
if not isinstance(data['tasks'], list):
|
||||
raise ValueError("tasks must be a list")
|
||||
invalid = [
|
||||
str(task) for task in data['tasks']
|
||||
if str(task).strip().lower() not in VALID_TASK_TYPES
|
||||
]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
f"tasks contains unsupported values: {', '.join(invalid)}"
|
||||
)
|
||||
else:
|
||||
dt = str(data.get('decoder_type', 'fm'))
|
||||
if dt not in valid_decoders:
|
||||
raise ValueError(f"decoder_type must be one of: {', '.join(sorted(valid_decoders))}")
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Meshcore device routes.
|
||||
|
||||
Endpoints for connecting to Meshcore devices (serial, TCP, BLE),
|
||||
streaming live events, and managing messages, contacts, and nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.meshcore import (
|
||||
BLEConfig,
|
||||
MeshcoreContact,
|
||||
SerialConfig,
|
||||
TCPConfig,
|
||||
get_meshcore_client,
|
||||
is_meshcore_available,
|
||||
list_serial_ports,
|
||||
)
|
||||
from utils.responses import api_error
|
||||
|
||||
logger = get_logger("intercept.meshcore")
|
||||
|
||||
meshcore_bp = Blueprint("meshcore", __name__, url_prefix="/meshcore")
|
||||
|
||||
|
||||
def _client():
|
||||
return get_meshcore_client()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status & connection management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/status")
|
||||
def status():
|
||||
if not is_meshcore_available():
|
||||
return jsonify(
|
||||
{
|
||||
"available": False,
|
||||
"state": "unavailable",
|
||||
"message": "meshcore package not installed. Run: pip install meshcore",
|
||||
}
|
||||
)
|
||||
c = _client()
|
||||
state, message = c.get_state()
|
||||
payload = {"available": True, "state": state.value}
|
||||
if message:
|
||||
payload["message"] = message
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@meshcore_bp.route("/connect", methods=["POST"])
|
||||
def connect():
|
||||
if not is_meshcore_available():
|
||||
return api_error("meshcore not installed", 503)
|
||||
data = request.get_json(silent=True) or {}
|
||||
transport = data.get("transport", "serial")
|
||||
|
||||
if transport == "serial":
|
||||
config = SerialConfig(port=data.get("port"), baud=int(data.get("baud", 115200)))
|
||||
elif transport == "tcp":
|
||||
host = data.get("host", "localhost")
|
||||
port = int(data.get("port", 5000))
|
||||
config = TCPConfig(host=host, port=port)
|
||||
elif transport == "ble":
|
||||
config = BLEConfig(device_address=data.get("address"))
|
||||
else:
|
||||
return api_error(f"Unknown transport: {transport}", 400)
|
||||
|
||||
_client().connect(config)
|
||||
return jsonify({"status": "connecting", "transport": transport})
|
||||
|
||||
|
||||
@meshcore_bp.route("/disconnect", methods=["POST"])
|
||||
def disconnect():
|
||||
_client().disconnect()
|
||||
return jsonify({"status": "disconnected"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/ports")
|
||||
def ports():
|
||||
return jsonify({"ports": list_serial_ports()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/ble/scan")
|
||||
def ble_scan():
|
||||
if not is_meshcore_available():
|
||||
return api_error("meshcore not installed", 503)
|
||||
devices = _client().scan_ble()
|
||||
return jsonify({"devices": devices})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/stream")
|
||||
def stream():
|
||||
def _gen():
|
||||
q = _client().get_queue()
|
||||
while True:
|
||||
try:
|
||||
event = q.get(timeout=30)
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except queue.Empty:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
return Response(
|
||||
_gen(),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/messages")
|
||||
def messages():
|
||||
return jsonify({"messages": _client().get_messages()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/send", methods=["POST"])
|
||||
def send():
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "").strip()
|
||||
recipient_id = data.get("recipient_id", "BROADCAST")
|
||||
if not text:
|
||||
return api_error("text is required", 400)
|
||||
if len(text) > 237:
|
||||
return api_error("text exceeds 237-character Meshcore limit", 400)
|
||||
_client().send_text(recipient_id, text)
|
||||
return jsonify({"status": "queued"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/nodes")
|
||||
def nodes():
|
||||
return jsonify({"nodes": _client().get_nodes()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/repeaters")
|
||||
def repeaters():
|
||||
return jsonify({"repeaters": _client().get_repeaters()})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/contacts", methods=["GET"])
|
||||
def list_contacts():
|
||||
return jsonify({"contacts": _client().get_contacts()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/contacts", methods=["POST"])
|
||||
def add_contact():
|
||||
data = request.get_json(silent=True) or {}
|
||||
node_id = data.get("node_id", "").strip()
|
||||
name = data.get("name", "").strip()
|
||||
public_key = data.get("public_key", "").strip()
|
||||
if not node_id or not name or not public_key:
|
||||
return api_error("node_id, name, and public_key are required", 400)
|
||||
contact = MeshcoreContact(node_id=node_id, name=name, public_key=public_key, last_msg=None)
|
||||
_client().add_contact(contact)
|
||||
return jsonify({"status": "added", "contact": contact.to_dict()})
|
||||
|
||||
|
||||
@meshcore_bp.route("/contacts/<node_id>", methods=["DELETE"])
|
||||
def delete_contact(node_id: str):
|
||||
removed = _client().remove_contact(node_id)
|
||||
if not removed:
|
||||
return api_error("contact not found", 404)
|
||||
return jsonify({"status": "removed"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telemetry & traceroute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@meshcore_bp.route("/telemetry/<node_id>")
|
||||
def telemetry(node_id: str):
|
||||
return jsonify({"node_id": node_id, "telemetry": _client().get_telemetry(node_id)})
|
||||
|
||||
|
||||
@meshcore_bp.route("/traceroute", methods=["POST"])
|
||||
def traceroute():
|
||||
data = request.get_json(silent=True) or {}
|
||||
node_id = data.get("node_id", "").strip()
|
||||
if not node_id:
|
||||
return api_error("node_id is required", 400)
|
||||
_client().request_traceroute(node_id)
|
||||
return jsonify({"status": "requested", "node_id": node_id})
|
||||
+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})
|
||||
|
||||
+169
-54
@@ -7,15 +7,16 @@ telemetry (position, altitude, temperature, humidity, pressure) on the
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -42,9 +43,10 @@ from utils.validation import (
|
||||
validate_longitude,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Track radiosonde state
|
||||
radiosonde_running = False
|
||||
@@ -66,8 +68,8 @@ AUTO_RX_PATHS = [
|
||||
]
|
||||
|
||||
|
||||
def find_auto_rx() -> str | None:
|
||||
"""Find radiosonde_auto_rx script/binary."""
|
||||
def find_auto_rx() -> str | None:
|
||||
"""Find radiosonde_auto_rx script/binary."""
|
||||
# Check PATH first
|
||||
path = shutil.which('radiosonde_auto_rx')
|
||||
if path:
|
||||
@@ -77,10 +79,123 @@ def find_auto_rx() -> str | None:
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
# Check for Python script (not executable but runnable)
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_shebang_interpreter(script_path: str) -> str | None:
|
||||
"""Resolve a Python interpreter from a script shebang if possible."""
|
||||
try:
|
||||
with open(script_path, encoding='utf-8', errors='ignore') as handle:
|
||||
first_line = handle.readline().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if not first_line.startswith('#!'):
|
||||
return None
|
||||
|
||||
parts = shlex.split(first_line[2:].strip())
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
if os.path.basename(parts[0]) == 'env' and len(parts) > 1:
|
||||
return shutil.which(parts[1])
|
||||
|
||||
return parts[0]
|
||||
|
||||
|
||||
def _resolve_pip_python(pip_bin: str | None) -> str | None:
|
||||
"""Resolve the Python interpreter used by a pip executable."""
|
||||
if not pip_bin:
|
||||
return None
|
||||
return _resolve_shebang_interpreter(pip_bin)
|
||||
|
||||
|
||||
def _build_auto_rx_env(auto_rx_dir: str) -> dict[str, str]:
|
||||
"""Build environment for radiosonde_auto_rx with compatibility shims."""
|
||||
env = os.environ.copy()
|
||||
python_path_entries = [PROJECT_ROOT, auto_rx_dir]
|
||||
existing_pythonpath = env.get('PYTHONPATH', '')
|
||||
if existing_pythonpath:
|
||||
python_path_entries.append(existing_pythonpath)
|
||||
env['PYTHONPATH'] = os.pathsep.join(entry for entry in python_path_entries if entry)
|
||||
return env
|
||||
|
||||
|
||||
def _iter_auto_rx_python_candidates(auto_rx_path: str):
|
||||
"""Yield plausible Python interpreters for radiosonde_auto_rx."""
|
||||
auto_rx_abs = os.path.abspath(auto_rx_path)
|
||||
auto_rx_dir = os.path.dirname(auto_rx_abs)
|
||||
install_root = os.path.dirname(auto_rx_dir)
|
||||
install_parent = os.path.dirname(install_root)
|
||||
|
||||
candidates = [
|
||||
_resolve_shebang_interpreter(auto_rx_abs),
|
||||
sys.executable,
|
||||
os.path.join(install_root, 'venv', 'bin', 'python'),
|
||||
os.path.join(install_root, 'venv', 'bin', 'python3'),
|
||||
os.path.join(install_root, '.venv', 'bin', 'python'),
|
||||
os.path.join(install_root, '.venv', 'bin', 'python3'),
|
||||
os.path.join(auto_rx_dir, 'venv', 'bin', 'python'),
|
||||
os.path.join(auto_rx_dir, 'venv', 'bin', 'python3'),
|
||||
os.path.join(auto_rx_dir, '.venv', 'bin', 'python'),
|
||||
os.path.join(auto_rx_dir, '.venv', 'bin', 'python3'),
|
||||
os.path.join(install_parent, 'venv', 'bin', 'python'),
|
||||
os.path.join(install_parent, 'venv', 'bin', 'python3'),
|
||||
os.path.join(install_parent, '.venv', 'bin', 'python'),
|
||||
os.path.join(install_parent, '.venv', 'bin', 'python3'),
|
||||
_resolve_pip_python(shutil.which('pip3')),
|
||||
_resolve_pip_python(shutil.which('pip')),
|
||||
shutil.which('python3'),
|
||||
shutil.which('python'),
|
||||
'/usr/local/bin/python3',
|
||||
'/usr/local/bin/python',
|
||||
'/usr/bin/python3',
|
||||
]
|
||||
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
candidate_abs = os.path.abspath(candidate)
|
||||
if candidate_abs in seen:
|
||||
continue
|
||||
seen.add(candidate_abs)
|
||||
if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK):
|
||||
yield candidate_abs
|
||||
|
||||
|
||||
def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]:
|
||||
"""Pick a Python interpreter that can import autorx.scan successfully."""
|
||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
|
||||
checked: list[str] = []
|
||||
last_error = 'No usable Python interpreter found'
|
||||
|
||||
for python_bin in _iter_auto_rx_python_candidates(auto_rx_path):
|
||||
checked.append(python_bin)
|
||||
try:
|
||||
dep_check = subprocess.run(
|
||||
[python_bin, '-c', 'import autorx.scan'],
|
||||
cwd=auto_rx_dir,
|
||||
env=auto_rx_env,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
continue
|
||||
|
||||
if dep_check.returncode == 0:
|
||||
return python_bin, '', checked
|
||||
|
||||
stderr_output = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
||||
stdout_output = dep_check.stdout.decode('utf-8', errors='ignore').strip()
|
||||
last_error = stderr_output or stdout_output or f'Interpreter exited with code {dep_check.returncode}'
|
||||
|
||||
return None, last_error, checked
|
||||
|
||||
|
||||
def generate_station_cfg(
|
||||
@@ -544,43 +659,43 @@ def start_radiosonde():
|
||||
logger.error(f"Failed to generate radiosonde config: {e}")
|
||||
return api_error(str(e), 500)
|
||||
|
||||
# Build command - auto_rx -c expects the path to station.cfg
|
||||
cfg_abs = os.path.abspath(cfg_path)
|
||||
if auto_rx_path.endswith('.py'):
|
||||
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
|
||||
else:
|
||||
cmd = [auto_rx_path, '-c', cfg_abs]
|
||||
|
||||
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||
|
||||
# Quick dependency check before launching the full process
|
||||
if auto_rx_path.endswith('.py'):
|
||||
dep_check = subprocess.run(
|
||||
[sys.executable, '-c', 'import autorx.scan'],
|
||||
cwd=auto_rx_dir,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if dep_check.returncode != 0:
|
||||
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
||||
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return api_error(
|
||||
'radiosonde_auto_rx dependencies not satisfied. '
|
||||
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
|
||||
500,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||
app_module.radiosonde_process = subprocess.Popen(
|
||||
cmd,
|
||||
# Build command - auto_rx -c expects the path to station.cfg
|
||||
cfg_abs = os.path.abspath(cfg_path)
|
||||
if auto_rx_path.endswith('.py'):
|
||||
selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path)
|
||||
if not selected_python:
|
||||
logger.error(
|
||||
"radiosonde_auto_rx dependency check failed across interpreters %s: %s",
|
||||
checked_interpreters,
|
||||
dep_error,
|
||||
)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none'
|
||||
return api_error(
|
||||
'radiosonde_auto_rx dependencies not satisfied. '
|
||||
'Install or repair its Python environment (missing packages such as semver). '
|
||||
f'Checked interpreters: {checked_msg}. '
|
||||
f'Last error: {dep_error[:500]}',
|
||||
500,
|
||||
)
|
||||
cmd = [selected_python, auto_rx_path, '-c', cfg_abs]
|
||||
else:
|
||||
cmd = [auto_rx_path, '-c', cfg_abs]
|
||||
|
||||
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
|
||||
|
||||
try:
|
||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||
app_module.radiosonde_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
cwd=auto_rx_dir,
|
||||
)
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
cwd=auto_rx_dir,
|
||||
env=auto_rx_env,
|
||||
)
|
||||
|
||||
# Wait briefly for process to start
|
||||
time.sleep(2.0)
|
||||
|
||||
+679
-304
File diff suppressed because it is too large
Load Diff
+122
-19
@@ -1,26 +1,101 @@
|
||||
"""Settings management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.database import (
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.database import (
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_correlations,
|
||||
get_setting,
|
||||
set_setting,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
from utils.validation import validate_latitude, validate_longitude
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
_env_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_env_file_path() -> Path:
|
||||
"""Return the project .env path."""
|
||||
return Path(__file__).resolve().parent.parent / '.env'
|
||||
|
||||
|
||||
def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None:
|
||||
"""Create or update a single key in the project .env file."""
|
||||
path = env_path or _get_env_file_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with _env_lock:
|
||||
lines = path.read_text().splitlines() if path.exists() else [
|
||||
'# INTERCEPT environment configuration',
|
||||
'',
|
||||
]
|
||||
|
||||
pattern = re.compile(rf'^\s*{re.escape(key)}=')
|
||||
updated = False
|
||||
new_lines: list[str] = []
|
||||
for line in lines:
|
||||
if pattern.match(line):
|
||||
if not updated:
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
if not updated:
|
||||
if new_lines and new_lines[-1] != '':
|
||||
new_lines.append('')
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
path.write_text('\n'.join(new_lines).rstrip('\n') + '\n')
|
||||
|
||||
sudo_uid = os.environ.get('INTERCEPT_SUDO_UID')
|
||||
sudo_gid = os.environ.get('INTERCEPT_SUDO_GID')
|
||||
if os.geteuid() == 0 and sudo_uid and sudo_gid:
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
os.chown(path, int(sudo_uid), int(sudo_gid))
|
||||
|
||||
|
||||
def _apply_runtime_observer_defaults(lat: float, lon: float) -> None:
|
||||
"""Update in-process defaults so refreshed pages use the saved location."""
|
||||
lat_str = str(lat)
|
||||
lon_str = str(lon)
|
||||
os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str
|
||||
os.environ['INTERCEPT_DEFAULT_LON'] = lon_str
|
||||
|
||||
import config
|
||||
|
||||
config.DEFAULT_LATITUDE = lat
|
||||
config.DEFAULT_LONGITUDE = lon
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
import app as app_module
|
||||
app_module.DEFAULT_LATITUDE = lat
|
||||
app_module.DEFAULT_LONGITUDE = lon
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
from routes import adsb as adsb_routes
|
||||
adsb_routes.DEFAULT_LATITUDE = lat
|
||||
adsb_routes.DEFAULT_LONGITUDE = lon
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
from routes import ais as ais_routes
|
||||
ais_routes.DEFAULT_LATITUDE = lat
|
||||
ais_routes.DEFAULT_LONGITUDE = lon
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['GET'])
|
||||
@@ -92,8 +167,8 @@ def update_single_setting(key: str) -> Response:
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
def delete_single_setting(key: str) -> Response:
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
def delete_single_setting(key: str) -> Response:
|
||||
"""Delete a setting."""
|
||||
try:
|
||||
deleted = delete_setting(key)
|
||||
@@ -106,7 +181,35 @@ def delete_single_setting(key: str) -> Response:
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting setting {key}: {e}")
|
||||
return api_error(str(e), 500)
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/observer-location', methods=['POST'])
|
||||
def save_observer_location() -> Response:
|
||||
"""Persist observer location to .env and refresh in-process defaults."""
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
lat = validate_latitude(data.get('lat'))
|
||||
lon = validate_longitude(data.get('lon'))
|
||||
except ValueError as exc:
|
||||
return api_error(str(exc), 400)
|
||||
|
||||
try:
|
||||
_write_env_value('INTERCEPT_DEFAULT_LAT', str(lat))
|
||||
_write_env_value('INTERCEPT_DEFAULT_LON', str(lon))
|
||||
_apply_runtime_observer_defaults(lat, lon)
|
||||
return api_success(
|
||||
data={
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'],
|
||||
},
|
||||
message='Observer location saved to .env',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f'Error saving observer location to .env: {exc}')
|
||||
return api_error(str(exc), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+228
-224
@@ -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,22 +434,23 @@ 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
|
||||
|
||||
|
||||
@@ -478,11 +460,12 @@ def _get_timescale():
|
||||
with _timescale_lock:
|
||||
if _timescale is None:
|
||||
from skyfield.api import load
|
||||
_timescale = load.timescale()
|
||||
|
||||
_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:
|
||||
|
||||
+30
-9
@@ -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
|
||||
@@ -36,7 +33,8 @@ logger = logging.getLogger('intercept.tscm')
|
||||
@tscm_bp.route('/status')
|
||||
def tscm_status():
|
||||
"""Check if any TSCM operation is currently running."""
|
||||
return jsonify({'running': _sweep_running})
|
||||
import routes.tscm as _tscm_pkg
|
||||
return jsonify({'running': _tscm_pkg._sweep_running})
|
||||
|
||||
|
||||
@tscm_bp.route('/sweep/start', methods=['POST'])
|
||||
@@ -57,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,
|
||||
@@ -67,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
|
||||
@@ -95,14 +113,15 @@ def stop_sweep():
|
||||
@tscm_bp.route('/sweep/status')
|
||||
def sweep_status():
|
||||
"""Get current sweep status."""
|
||||
import routes.tscm as _tscm_pkg
|
||||
|
||||
status = {
|
||||
'running': _sweep_running,
|
||||
'sweep_id': _current_sweep_id,
|
||||
'running': _tscm_pkg._sweep_running,
|
||||
'sweep_id': _tscm_pkg._current_sweep_id,
|
||||
}
|
||||
|
||||
if _current_sweep_id:
|
||||
sweep = get_tscm_sweep(_current_sweep_id)
|
||||
if _tscm_pkg._current_sweep_id:
|
||||
sweep = get_tscm_sweep(_tscm_pkg._current_sweep_id)
|
||||
if sweep:
|
||||
status['sweep'] = sweep
|
||||
|
||||
@@ -113,12 +132,14 @@ def sweep_status():
|
||||
def sweep_stream():
|
||||
"""SSE stream for real-time sweep updates."""
|
||||
|
||||
import routes.tscm as _tscm_pkg
|
||||
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('tscm', msg, msg.get('type'))
|
||||
|
||||
return Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=tscm_queue,
|
||||
source_queue=_tscm_pkg.tscm_queue,
|
||||
channel_key='tscm',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
|
||||
+27
-3
@@ -83,11 +83,35 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
data['type'] = 'vdl2'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Enrich with translated ACARS label at top level (consistent with ACARS route)
|
||||
# Flatten nested VDL2 identifying fields to top level for correlator matching
|
||||
# dumpvdl2 nests flight/reg inside vdl2.avlc.acars and ICAO in avlc.src.addr
|
||||
try:
|
||||
vdl2_inner = data.get('vdl2', data)
|
||||
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
|
||||
if acars_payload and acars_payload.get('label'):
|
||||
avlc = vdl2_inner.get('avlc') or {}
|
||||
acars_payload = avlc.get('acars') or {}
|
||||
|
||||
# Promote AVLC source address — this is the aircraft ICAO hex
|
||||
# Do this FIRST so even non-ACARS VDL2 frames can be correlated
|
||||
src = avlc.get('src') or {}
|
||||
src_addr = src.get('addr', '')
|
||||
src_type = src.get('type', '')
|
||||
if src_addr and src_type == 'Aircraft':
|
||||
data['icao'] = src_addr.upper()
|
||||
data['addr'] = src_addr.upper()
|
||||
|
||||
# Promote ACARS fields to top level so FlightCorrelator can match them
|
||||
if acars_payload.get('flight'):
|
||||
data['flight'] = acars_payload['flight']
|
||||
if acars_payload.get('reg'):
|
||||
data['reg'] = acars_payload['reg']
|
||||
data['tail'] = acars_payload['reg']
|
||||
if acars_payload.get('label'):
|
||||
data['label'] = acars_payload['label']
|
||||
if acars_payload.get('msg_text'):
|
||||
data['text'] = acars_payload['msg_text']
|
||||
|
||||
# Enrich with translated ACARS label (consistent with ACARS route)
|
||||
if acars_payload.get('label'):
|
||||
translation = translate_message({
|
||||
'label': acars_payload.get('label'),
|
||||
'text': acars_payload.get('msg_text', ''),
|
||||
|
||||
+125
-15
@@ -1,12 +1,15 @@
|
||||
"""Weather Satellite decoder routes.
|
||||
|
||||
Provides endpoints for capturing and decoding weather satellite images
|
||||
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||
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
|
||||
|
||||
@@ -37,6 +40,15 @@ 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',
|
||||
)
|
||||
|
||||
|
||||
def _progress_callback(progress: CaptureProgress) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
@@ -120,9 +132,9 @@ def start_capture():
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // 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)
|
||||
}
|
||||
|
||||
@@ -164,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({
|
||||
@@ -248,7 +260,7 @@ def test_decode():
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // 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)
|
||||
}
|
||||
@@ -292,14 +304,13 @@ def test_decode():
|
||||
from pathlib import Path
|
||||
input_path = Path(input_file)
|
||||
|
||||
# Security: restrict to data directory (anchored to app root, not CWD)
|
||||
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||
# Restrict test-decode to application-owned sample and recording paths.
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
if not resolved.is_relative_to(allowed_base):
|
||||
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 the data/ directory'
|
||||
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
|
||||
}), 403
|
||||
except (OSError, ValueError):
|
||||
return jsonify({
|
||||
@@ -389,21 +400,34 @@ def list_images():
|
||||
JSON with list of decoded images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
images = decoder.get_images()
|
||||
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 img.satellite == 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:]
|
||||
images = images[:limit]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'images': images,
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
@@ -436,6 +460,36 @@ def get_image(filename: str):
|
||||
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'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded image.
|
||||
@@ -469,6 +523,62 @@ def 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')
|
||||
def stream_progress():
|
||||
"""SSE stream of capture/decode progress.
|
||||
@@ -579,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)
|
||||
}
|
||||
|
||||
|
||||
+12
-7
@@ -673,13 +673,6 @@ def start_wifi_scan():
|
||||
os.remove(f)
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
airodump_path,
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
'--band', band,
|
||||
interface
|
||||
]
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
@@ -688,10 +681,22 @@ def start_wifi_scan():
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
cmd = [
|
||||
airodump_path,
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
]
|
||||
|
||||
# --band and -c are mutually exclusive: only add --band when not
|
||||
# locking to specific channels, and always place the interface last.
|
||||
if channel_list:
|
||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||
elif channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
else:
|
||||
cmd.extend(['--band', band])
|
||||
|
||||
cmd.append(interface)
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Minimal semver compatibility shim.
|
||||
|
||||
This project vendors a tiny subset of the ``semver`` package API so
|
||||
integrations like radiosonde_auto_rx can run even when the external
|
||||
dependency is missing from the target Python environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Iterable
|
||||
|
||||
_SEMVER_RE = re.compile(
|
||||
r"^\s*"
|
||||
r"(?P<major>0|[1-9]\d*)"
|
||||
r"(?:\.(?P<minor>0|[1-9]\d*))?"
|
||||
r"(?:\.(?P<patch>0|[1-9]\d*))?"
|
||||
r"(?:-(?P<prerelease>[0-9A-Za-z.-]+))?"
|
||||
r"(?:\+(?P<build>[0-9A-Za-z.-]+))?"
|
||||
r"\s*$"
|
||||
)
|
||||
|
||||
|
||||
def _split_prerelease(value: str | None) -> list[int | str]:
|
||||
if not value:
|
||||
return []
|
||||
parts: list[int | str] = []
|
||||
for token in value.split("."):
|
||||
parts.append(int(token) if token.isdigit() else token)
|
||||
return parts
|
||||
|
||||
|
||||
def _compare_identifiers(left: Iterable[int | str], right: Iterable[int | str]) -> int:
|
||||
left_parts = list(left)
|
||||
right_parts = list(right)
|
||||
for l_part, r_part in zip(left_parts, right_parts):
|
||||
if l_part == r_part:
|
||||
continue
|
||||
if isinstance(l_part, int) and isinstance(r_part, str):
|
||||
return -1
|
||||
if isinstance(l_part, str) and isinstance(r_part, int):
|
||||
return 1
|
||||
return -1 if l_part < r_part else 1
|
||||
if len(left_parts) == len(right_parts):
|
||||
return 0
|
||||
return -1 if len(left_parts) < len(right_parts) else 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VersionInfo:
|
||||
major: int
|
||||
minor: int = 0
|
||||
patch: int = 0
|
||||
prerelease: str | None = None
|
||||
build: str | None = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, version: str) -> VersionInfo:
|
||||
match = _SEMVER_RE.match(str(version))
|
||||
if not match:
|
||||
raise ValueError(f"{version!r} is not valid SemVer")
|
||||
groups = match.groupdict()
|
||||
return cls(
|
||||
major=int(groups["major"]),
|
||||
minor=int(groups["minor"] or 0),
|
||||
patch=int(groups["patch"] or 0),
|
||||
prerelease=groups["prerelease"],
|
||||
build=groups["build"],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def isvalid(cls, version: str) -> bool:
|
||||
return _SEMVER_RE.match(str(version)) is not None
|
||||
|
||||
@classmethod
|
||||
def is_valid(cls, version: str) -> bool:
|
||||
return cls.isvalid(version)
|
||||
|
||||
def compare(self, other: str | VersionInfo) -> int:
|
||||
return compare(self, other)
|
||||
|
||||
def match(self, expr: str) -> bool:
|
||||
return match(str(self), expr)
|
||||
|
||||
def bump_major(self) -> VersionInfo:
|
||||
return VersionInfo(self.major + 1, 0, 0)
|
||||
|
||||
def bump_minor(self) -> VersionInfo:
|
||||
return VersionInfo(self.major, self.minor + 1, 0)
|
||||
|
||||
def bump_patch(self) -> VersionInfo:
|
||||
return VersionInfo(self.major, self.minor, self.patch + 1)
|
||||
|
||||
def finalize_version(self) -> VersionInfo:
|
||||
return VersionInfo(self.major, self.minor, self.patch)
|
||||
|
||||
def replace(self, **changes) -> VersionInfo:
|
||||
return replace(self, **changes)
|
||||
|
||||
def __str__(self) -> str:
|
||||
value = f"{self.major}.{self.minor}.{self.patch}"
|
||||
if self.prerelease:
|
||||
value += f"-{self.prerelease}"
|
||||
if self.build:
|
||||
value += f"+{self.build}"
|
||||
return value
|
||||
|
||||
|
||||
def parse(version: str) -> VersionInfo:
|
||||
return VersionInfo.parse(version)
|
||||
|
||||
|
||||
def compare(left: str | VersionInfo, right: str | VersionInfo) -> int:
|
||||
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
|
||||
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
|
||||
|
||||
left_core = (left_ver.major, left_ver.minor, left_ver.patch)
|
||||
right_core = (right_ver.major, right_ver.minor, right_ver.patch)
|
||||
if left_core != right_core:
|
||||
return -1 if left_core < right_core else 1
|
||||
|
||||
if left_ver.prerelease == right_ver.prerelease:
|
||||
return 0
|
||||
if left_ver.prerelease is None:
|
||||
return 1
|
||||
if right_ver.prerelease is None:
|
||||
return -1
|
||||
return _compare_identifiers(
|
||||
_split_prerelease(left_ver.prerelease),
|
||||
_split_prerelease(right_ver.prerelease),
|
||||
)
|
||||
|
||||
|
||||
def match(version: str | VersionInfo, expr: str) -> bool:
|
||||
version_info = version if isinstance(version, VersionInfo) else parse(str(version))
|
||||
expression = str(expr).strip()
|
||||
for operator in ("<=", ">=", "==", "!=", "<", ">"):
|
||||
if expression.startswith(operator):
|
||||
other = parse(expression[len(operator):].strip())
|
||||
result = compare(version_info, other)
|
||||
return {
|
||||
"<": result < 0,
|
||||
"<=": result <= 0,
|
||||
">": result > 0,
|
||||
">=": result >= 0,
|
||||
"==": result == 0,
|
||||
"!=": result != 0,
|
||||
}[operator]
|
||||
return compare(version_info, parse(expression)) == 0
|
||||
|
||||
|
||||
def max_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
|
||||
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
|
||||
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
|
||||
return left_ver if compare(left_ver, right_ver) >= 0 else right_ver
|
||||
|
||||
|
||||
def min_ver(left: str | VersionInfo, right: str | VersionInfo) -> VersionInfo:
|
||||
left_ver = left if isinstance(left, VersionInfo) else parse(str(left))
|
||||
right_ver = right if isinstance(right, VersionInfo) else parse(str(right))
|
||||
return left_ver if compare(left_ver, right_ver) <= 0 else right_ver
|
||||
@@ -438,7 +438,11 @@ check_tools() {
|
||||
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py
|
||||
if [[ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]] && [[ -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]]; then
|
||||
ok "auto_rx.py - Radiosonde weather balloon decoder"
|
||||
else
|
||||
warn "auto_rx.py - Radiosonde weather balloon decoder (missing, optional)"
|
||||
fi
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
@@ -487,6 +491,16 @@ import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
||||
PY
|
||||
ok "Python version OK (>= 3.9)"
|
||||
|
||||
# Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have
|
||||
# pre-built wheels yet and will be skipped to avoid hanging on compilation.
|
||||
if python3 - <<'PY'
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3,13) else 1)
|
||||
PY
|
||||
then
|
||||
warn "Python 3.13+ detected: optional packages without pre-built wheels will be skipped (--prefer-binary)."
|
||||
fi
|
||||
}
|
||||
|
||||
install_python_deps() {
|
||||
@@ -520,8 +534,11 @@ install_python_deps() {
|
||||
source venv/bin/activate
|
||||
local PIP="venv/bin/python -m pip"
|
||||
local PY="venv/bin/python"
|
||||
# --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue)
|
||||
# --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections
|
||||
local PIP_OPTS="--no-cache-dir --timeout 120"
|
||||
|
||||
if ! $PIP install --upgrade pip setuptools wheel; then
|
||||
if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then
|
||||
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
|
||||
else
|
||||
ok "Upgraded pip tooling"
|
||||
@@ -530,24 +547,39 @@ install_python_deps() {
|
||||
progress "Installing Python dependencies"
|
||||
|
||||
info "Installing core packages..."
|
||||
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||
"Werkzeug>=3.1.5" "pyserial>=3.5" 2>/dev/null || true
|
||||
$PIP install $PIP_OPTS "flask>=3.0.0" "flask-wtf>=1.2.0" "flask-compress>=1.15" \
|
||||
"flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||
"Werkzeug>=3.1.5" "pyserial>=3.5" || true
|
||||
|
||||
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||
echo "Try: venv/bin/pip install flask requests flask-limiter"
|
||||
exit 1
|
||||
}
|
||||
# Verify core packages are installed by checking pip's reported list (avoids hanging imports)
|
||||
for core_pkg in flask requests flask-limiter flask-compress flask-wtf; do
|
||||
if ! $PIP show "$core_pkg" >/dev/null 2>&1; then
|
||||
fail "Critical Python package not installed: ${core_pkg}"
|
||||
echo "Try: venv/bin/pip install ${core_pkg}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
ok "Core Python packages installed"
|
||||
|
||||
info "Installing optional packages..."
|
||||
for pkg in "flask-sock" "websocket-client>=1.6.0" "numpy>=1.24.0" "scipy>=1.10.0" \
|
||||
"Pillow>=9.0.0" "skyfield>=1.45" "bleak>=0.21.0" "psycopg2-binary>=2.9.9" \
|
||||
"meshtastic>=2.0.0" "scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0" \
|
||||
"gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do
|
||||
pkg_name="${pkg%%>=*}"
|
||||
# Pure-Python packages: install without --only-binary so they always succeed regardless of platform
|
||||
for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \
|
||||
"skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \
|
||||
"qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do
|
||||
pkg_name="${pkg%%[><=]*}"
|
||||
info " Installing ${pkg_name}..."
|
||||
if ! $PIP install "$pkg"; then
|
||||
if ! $PIP install $PIP_OPTS "$pkg"; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
# Compiled packages: use --only-binary :all: to skip slow source compilation on RPi
|
||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" \
|
||||
"psycopg2-binary>=2.9.9" "scapy>=2.4.5" "cryptography>=41.0.0" \
|
||||
"gevent>=23.9.0"; do
|
||||
pkg_name="${pkg%%[><=]*}"
|
||||
info " Installing ${pkg_name}..."
|
||||
# --only-binary :all: prevents source compilation hangs for heavy packages
|
||||
if ! $PIP install $PIP_OPTS --only-binary :all: "$pkg"; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
@@ -603,7 +635,25 @@ apt_install() {
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_apt_lock() {
|
||||
local max_wait=120
|
||||
local waited=0
|
||||
while fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; do
|
||||
if [[ $waited -eq 0 ]]; then
|
||||
info "Waiting for apt lock (another package manager is running)..."
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
if [[ $waited -ge $max_wait ]]; then
|
||||
warn "apt lock held for over ${max_wait}s. Continuing anyway (may fail)."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
apt_try_install_any() {
|
||||
wait_for_apt_lock
|
||||
local p
|
||||
for p in "$@"; do
|
||||
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
|
||||
@@ -1720,6 +1770,7 @@ install_profiles() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
wait_for_apt_lock
|
||||
info "Updating APT package lists..."
|
||||
if ! $SUDO apt-get update -y >/dev/null 2>&1; then
|
||||
warn "apt-get update reported errors. Continuing anyway."
|
||||
@@ -2012,8 +2063,8 @@ do_health_check() {
|
||||
ok "Python venv exists"
|
||||
((pass++)) || true
|
||||
|
||||
if venv/bin/python -c "import flask; import requests" 2>/dev/null; then
|
||||
ok "Critical Python packages (flask, requests) — OK"
|
||||
if venv/bin/python -s -c "import flask; import requests; import flask_compress; import flask_wtf" 2>/dev/null; then
|
||||
ok "Critical Python packages (flask, requests, flask-compress, flask-wtf) — OK"
|
||||
((pass++)) || true
|
||||
else
|
||||
fail "Critical Python packages missing in venv"
|
||||
|
||||
@@ -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
@@ -0,0 +1,158 @@
|
||||
/* Drone Intelligence Styles */
|
||||
|
||||
/* ── Main visuals panel ── */
|
||||
.drone-visuals-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.drone-visuals-header {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.drone-visuals-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.drone-vsstat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.drone-vsstat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.drone-vsstat-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.drone-visuals-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drone-contact-panel {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.drone-main-map {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.drone-empty-state {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.drone-vector-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.drone-vector-pill {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.drone-vector-pill.active {
|
||||
background: color-mix(in srgb, var(--accent-cyan) 15%, transparent);
|
||||
color: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.drone-contact-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.drone-contact-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.drone-contact-card.high-risk {
|
||||
border-left: 3px solid var(--accent-red);
|
||||
}
|
||||
|
||||
.drone-contact-card.medium-risk {
|
||||
border-left: 3px solid var(--accent-yellow);
|
||||
}
|
||||
|
||||
.drone-contact-card.low-risk {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.drone-compliance-badge {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drone-compliance-badge.compliant {
|
||||
background: color-mix(in srgb, var(--accent-green) 20%, transparent);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.drone-compliance-badge.non-compliant {
|
||||
background: color-mix(in srgb, var(--accent-red) 20%, transparent);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
|
||||
.drone-marker-high-risk {
|
||||
animation: dsc-distress-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes dsc-distress-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.4); }
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/* Meshcore mode — scoped styles */
|
||||
|
||||
/* ── Sidebar hiding (same rules as meshtastic.css — needed here since
|
||||
meshtastic.css is only lazily loaded when that mode is visited) ── */
|
||||
.main-content.mesh-sidebar-hidden {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-content.mesh-sidebar-hidden > .sidebar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.main-content.mesh-sidebar-hidden > .output-panel {
|
||||
flex: 1 !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Visuals container (base rules duplicated from meshtastic.css — lazy-load safety) ── */
|
||||
#meshcoreVisuals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* meshcoreMode is an empty wrapper kept only for JS active-class toggle */
|
||||
#meshcoreMode { display: none; }
|
||||
|
||||
/* ── Connection strip ── */
|
||||
.meshcore-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meshcore-strip-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meshcore-strip-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.meshcore-strip-status-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meshcore-strip-select {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.meshcore-strip-input {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.meshcore-strip-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.meshcore-strip-btn:hover { opacity: 0.85; }
|
||||
.meshcore-strip-btn.connect { background: var(--accent-cyan); color: #000; border-color: var(--accent-cyan); }
|
||||
.meshcore-strip-btn.disconnect { border-color: #f44336; color: #f44336; }
|
||||
.meshcore-strip-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Transport tabs in strip */
|
||||
.meshcore-transport-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.meshcore-transport-tab {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.meshcore-transport-tab.active {
|
||||
background: var(--accent-cyan);
|
||||
color: #000;
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Strip stats */
|
||||
.meshcore-strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.meshcore-strip-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.meshcore-strip-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Status dot ── */
|
||||
.meshcore-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meshcore-status-dot.connected { background: #4caf50; box-shadow: 0 0 5px #4caf50; }
|
||||
.meshcore-status-dot.connecting { background: #ff9800; animation: meshcore-pulse 1s infinite; }
|
||||
.meshcore-status-dot.error { background: #f44336; }
|
||||
|
||||
@keyframes meshcore-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* ── Body (panel + content) ── */
|
||||
.meshcore-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Left contacts/nodes panel ── */
|
||||
.meshcore-panel {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meshcore-panel-section {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.meshcore-panel-section--grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.meshcore-panel-title {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Node / contact list items ── */
|
||||
.meshcore-node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meshcore-node-item:last-child { border-bottom: none; }
|
||||
|
||||
.meshcore-node-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meshcore-node-icon.repeater {
|
||||
border-radius: 0;
|
||||
clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
|
||||
background: #ff9800;
|
||||
}
|
||||
|
||||
.meshcore-node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.meshcore-node-meta { font-size: 10px; color: var(--text-muted); }
|
||||
|
||||
.meshcore-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meshcore-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Right content ── */
|
||||
.meshcore-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Tab bar ── */
|
||||
.meshcore-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meshcore-tab {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.meshcore-tab.active {
|
||||
color: var(--accent-cyan);
|
||||
border-bottom-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ── Tab panels ── */
|
||||
.meshcore-tab-panel {
|
||||
display: none;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meshcore-tab-panel.active { display: flex; }
|
||||
|
||||
/* ── Message feed ── */
|
||||
.meshcore-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meshcore-message {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meshcore-message.pending { opacity: 0.6; border-style: dashed; }
|
||||
.meshcore-message.direct { border-left: 3px solid var(--accent-cyan); }
|
||||
|
||||
.meshcore-message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meshcore-message-sender { color: var(--accent-cyan); font-weight: 600; }
|
||||
.meshcore-message-text { color: var(--text-primary); }
|
||||
|
||||
/* ── Compose bar ── */
|
||||
.meshcore-compose {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meshcore-compose-select {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.meshcore-compose-input {
|
||||
flex: 1;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.meshcore-compose-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.meshcore-compose-btn {
|
||||
padding: 6px 16px;
|
||||
background: var(--accent-cyan);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Map tab ── */
|
||||
#meshcoreTabMap { overflow: hidden; }
|
||||
|
||||
#meshcoreMap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* ── Repeaters table ── */
|
||||
.meshcore-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meshcore-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.meshcore-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Traceroute modal ── */
|
||||
.meshcore-traceroute-hops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.meshcore-hop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meshcore-hop-node {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.meshcore-hop-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -107,6 +107,23 @@
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* ===== Timezone Select ===== */
|
||||
.wxsat-tz-select {
|
||||
padding: 3px 6px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wxsat-tz-select:focus {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ===== Auto-Schedule Toggle ===== */
|
||||
.wxsat-schedule-toggle {
|
||||
display: flex;
|
||||
@@ -317,6 +334,161 @@
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Pass Analysis Bar ===== */
|
||||
.wxsat-analysis-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-analysis-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wxsat-analysis-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wxsat-analysis-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.wxsat-analysis-value.excellent { color: var(--neon-green); }
|
||||
.wxsat-analysis-value.good { color: var(--accent-cyan); }
|
||||
.wxsat-analysis-value.fair { color: var(--accent-yellow); }
|
||||
|
||||
.wxsat-analysis-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wxsat-analysis-best {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Best Pass Badge ===== */
|
||||
.wxsat-pass-best-badge {
|
||||
display: inline-block;
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: var(--neon-green);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* ===== Pass Direction ===== */
|
||||
.wxsat-pass-direction {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.wxsat-pass-direction .wxsat-dir-arrow {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* ===== Pass Geometry Detail ===== */
|
||||
.wxsat-pass-geometry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-geom-event {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.wxsat-geom-event.wxsat-geom-tca {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.wxsat-geom-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wxsat-geom-tca .wxsat-geom-label {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.wxsat-geom-time {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.wxsat-geom-tca .wxsat-geom-time {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.wxsat-geom-az {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.wxsat-geom-arrow {
|
||||
font-size: 14px;
|
||||
color: var(--text-dim, #444);
|
||||
}
|
||||
|
||||
.wxsat-geom-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
margin-left: 8px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border-color, #2a3040);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Countdown Pulse Animation ===== */
|
||||
.wxsat-countdown-box.imminent .wxsat-cd-value {
|
||||
animation: wxsat-count-pulse 1s ease-in-out infinite;
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.wxsat-countdown-box.active .wxsat-cd-value {
|
||||
animation: wxsat-count-pulse 1s ease-in-out infinite;
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
@keyframes wxsat-count-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.15); opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ===== Pass Predictions Panel ===== */
|
||||
.wxsat-passes-panel {
|
||||
flex: 0 0 280px;
|
||||
@@ -1066,6 +1238,51 @@
|
||||
color: var(--text-dim, #444);
|
||||
}
|
||||
|
||||
/* Console filter buttons */
|
||||
.wxsat-console-filters {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.wxsat-console-filter {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text-dim, #555);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wxsat-console-filter:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
.wxsat-console-filter.active {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.wxsat-console-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Console entry timestamps */
|
||||
.wxsat-console-ts {
|
||||
color: var(--text-dim, #444);
|
||||
margin-right: 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
#wxsatConsoleToggle {
|
||||
font-size: 10px;
|
||||
width: 28px;
|
||||
|
||||
+1033
-58
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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+33
-21
@@ -10,10 +10,11 @@ let currentAgent = 'local';
|
||||
let agentEventSource = null;
|
||||
let multiAgentMode = false; // Show combined results from all agents
|
||||
let multiAgentPollInterval = null;
|
||||
let agentRunningModes = []; // Track agent's running modes for conflict detection
|
||||
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
|
||||
let healthCheckInterval = null; // Health monitoring interval
|
||||
let agentHealthStatus = {}; // Cache of health status per agent ID
|
||||
let agentRunningModes = []; // Track agent's running modes for conflict detection
|
||||
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
|
||||
let healthCheckInterval = null; // Health monitoring interval
|
||||
let agentHealthStatus = {}; // Cache of health status per agent ID
|
||||
let healthCheckKickoffTimer = null;
|
||||
|
||||
// ============== AGENT HEALTH MONITORING ==============
|
||||
|
||||
@@ -21,27 +22,38 @@ let agentHealthStatus = {}; // Cache of health status per agent ID
|
||||
* Start periodic health monitoring for all agents.
|
||||
* Runs every 30 seconds to check agent health status.
|
||||
*/
|
||||
function startHealthMonitoring() {
|
||||
// Don't start if already running
|
||||
if (healthCheckInterval) return;
|
||||
|
||||
// Initial check
|
||||
checkAllAgentsHealth();
|
||||
|
||||
// Start periodic checks every 30 seconds
|
||||
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
|
||||
console.log('[AgentManager] Health monitoring started (30s interval)');
|
||||
}
|
||||
function startHealthMonitoring() {
|
||||
// Don't start if already running
|
||||
if (healthCheckInterval) return;
|
||||
|
||||
// Defer the first probe so heavy dashboards can finish initial render
|
||||
// before we start contacting remote agents.
|
||||
if (healthCheckKickoffTimer) {
|
||||
clearTimeout(healthCheckKickoffTimer);
|
||||
}
|
||||
healthCheckKickoffTimer = setTimeout(() => {
|
||||
healthCheckKickoffTimer = null;
|
||||
checkAllAgentsHealth();
|
||||
}, 5000);
|
||||
|
||||
// Start periodic checks every 30 seconds
|
||||
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
|
||||
console.log('[AgentManager] Health monitoring started (30s interval)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop health monitoring.
|
||||
*/
|
||||
function stopHealthMonitoring() {
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
console.log('[AgentManager] Health monitoring stopped');
|
||||
}
|
||||
function stopHealthMonitoring() {
|
||||
if (healthCheckKickoffTimer) {
|
||||
clearTimeout(healthCheckKickoffTimer);
|
||||
healthCheckKickoffTimer = null;
|
||||
}
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
console.log('[AgentManager] Health monitoring stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+103
-33
@@ -8,16 +8,41 @@ const AlertCenter = (function() {
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
let lastConnectionWarningAt = 0;
|
||||
let rulesLoaded = false;
|
||||
let rulesPromise = null;
|
||||
let bootTimer = null;
|
||||
let feedLoaded = false;
|
||||
|
||||
function init() {
|
||||
loadRules();
|
||||
loadFeed();
|
||||
connect();
|
||||
function init(options = {}) {
|
||||
const connectFeed = options.connectFeed !== false;
|
||||
const refreshRules = options.refreshRules === true;
|
||||
|
||||
if (bootTimer) {
|
||||
clearTimeout(bootTimer);
|
||||
bootTimer = null;
|
||||
}
|
||||
|
||||
loadRules(refreshRules);
|
||||
|
||||
if (connectFeed) {
|
||||
if (!feedLoaded) {
|
||||
loadFeed();
|
||||
}
|
||||
connect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleInit(delayMs = 15000) {
|
||||
if (bootTimer || eventSource) return;
|
||||
bootTimer = window.setTimeout(() => {
|
||||
bootTimer = null;
|
||||
init();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
return;
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/alerts/stream');
|
||||
@@ -40,6 +65,10 @@ const AlertCenter = (function() {
|
||||
lastConnectionWarningAt = now;
|
||||
console.warn('[Alerts] SSE connection error; retrying');
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2500);
|
||||
};
|
||||
@@ -133,6 +162,7 @@ const AlertCenter = (function() {
|
||||
}
|
||||
|
||||
function loadFeed() {
|
||||
feedLoaded = true;
|
||||
fetch('/alerts/events?limit=30')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
@@ -144,21 +174,37 @@ const AlertCenter = (function() {
|
||||
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
||||
}
|
||||
|
||||
function loadRules() {
|
||||
return fetch('/alerts/rules?all=1')
|
||||
function loadRules(force = false) {
|
||||
if (!force && rulesLoaded) {
|
||||
renderRulesUI();
|
||||
return Promise.resolve(rules);
|
||||
}
|
||||
if (!force && rulesPromise) {
|
||||
return rulesPromise;
|
||||
}
|
||||
|
||||
rulesPromise = fetch('/alerts/rules?all=1')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
rules = data.rules || [];
|
||||
rulesLoaded = true;
|
||||
renderRulesUI();
|
||||
}
|
||||
return rules;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Alerts] Load rules failed', err);
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
rulesPromise = null;
|
||||
});
|
||||
|
||||
return rulesPromise;
|
||||
}
|
||||
|
||||
function saveRule() {
|
||||
@@ -260,7 +306,7 @@ const AlertCenter = (function() {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to update rule');
|
||||
}
|
||||
return loadRules();
|
||||
return loadRules(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
@@ -287,7 +333,7 @@ const AlertCenter = (function() {
|
||||
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
||||
clearRuleForm();
|
||||
}
|
||||
return loadRules();
|
||||
return loadRules(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
@@ -325,7 +371,7 @@ const AlertCenter = (function() {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
}).then(() => loadRules());
|
||||
}).then(() => loadRules(true));
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
@@ -341,7 +387,7 @@ const AlertCenter = (function() {
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
}).then(() => loadRules(true));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -349,41 +395,63 @@ const AlertCenter = (function() {
|
||||
|
||||
function addBluetoothWatchlist(address, name) {
|
||||
if (!address) return;
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (existing) return;
|
||||
loadRules().then(() => {
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (existing) return;
|
||||
|
||||
fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { address: upper },
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
return fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { address: upper },
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules(true));
|
||||
});
|
||||
}
|
||||
|
||||
function removeBluetoothWatchlist(address) {
|
||||
if (!address) return;
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (!existing) return;
|
||||
loadRules().then(() => {
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (!existing) return;
|
||||
|
||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||
.then(() => loadRules());
|
||||
return fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||
.then(() => loadRules(true));
|
||||
});
|
||||
}
|
||||
|
||||
function isWatchlisted(address) {
|
||||
if (!address) return false;
|
||||
if (!rulesLoaded && !rulesPromise) {
|
||||
loadRules();
|
||||
}
|
||||
const upper = String(address).toUpperCase();
|
||||
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (bootTimer) {
|
||||
clearTimeout(bootTimer);
|
||||
bootTimer = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
@@ -396,6 +464,7 @@ const AlertCenter = (function() {
|
||||
|
||||
return {
|
||||
init,
|
||||
scheduleInit,
|
||||
loadFeed,
|
||||
loadRules,
|
||||
saveRule,
|
||||
@@ -408,11 +477,12 @@ const AlertCenter = (function() {
|
||||
addBluetoothWatchlist,
|
||||
removeBluetoothWatchlist,
|
||||
isWatchlisted,
|
||||
destroy,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.init();
|
||||
AlertCenter.scheduleInit();
|
||||
}
|
||||
});
|
||||
|
||||
+23
-3
@@ -77,8 +77,23 @@ function declineDisclaimer() {
|
||||
|
||||
function updateHeaderClock() {
|
||||
const now = new Date();
|
||||
const utc = now.toISOString().substring(11, 19);
|
||||
document.getElementById('headerUtcTime').textContent = utc;
|
||||
const el = document.getElementById('headerUtcTime');
|
||||
const label = document.querySelector('.utc-label');
|
||||
if (typeof InterceptTime !== 'undefined') {
|
||||
if (el) el.textContent = InterceptTime.fullTime(now);
|
||||
if (label) label.textContent = InterceptTime.getLabel() || 'LOCAL';
|
||||
} else {
|
||||
if (el) el.textContent = now.toISOString().substring(11, 19);
|
||||
}
|
||||
}
|
||||
|
||||
function initTimeSettings() {
|
||||
const tzSelect = document.getElementById('globalTimezoneSelect');
|
||||
const fmtSelect = document.getElementById('globalTimeFormatSelect');
|
||||
if (typeof InterceptTime !== 'undefined') {
|
||||
if (tzSelect) tzSelect.value = InterceptTime.getTimezone();
|
||||
if (fmtSelect) fmtSelect.value = InterceptTime.getHour12() ? '12' : '24';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== MODE SWITCHING ==============
|
||||
@@ -447,9 +462,14 @@ function initApp() {
|
||||
// Load theme
|
||||
loadTheme();
|
||||
|
||||
// Start clock
|
||||
// Start clock and init time settings
|
||||
initTimeSettings();
|
||||
updateHeaderClock();
|
||||
window._navClockStarted = true; // Prevent nav.html from starting a duplicate interval
|
||||
setInterval(updateHeaderClock, 1000);
|
||||
if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
|
||||
InterceptTime.onChange(updateHeaderClock);
|
||||
}
|
||||
|
||||
// Load bias-T setting
|
||||
loadBiasTSetting();
|
||||
|
||||
@@ -19,9 +19,11 @@ const CheatSheets = (function () {
|
||||
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
controller_monitor: { title: 'Controller Monitor', icon: '🖧', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||
drone: { title: 'Drone Intelligence', icon: '🚁', hardware: 'WiFi adapter (monitor mode) + RTL-SDR + optional HackRF', description: 'Multi-vector UAV detection: ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, HackRF 2.4/5.8 GHz.', whatToExpect: 'Drone contacts with ID, operator, GPS position (if broadcast), detection vectors, and risk level.', tips: ['Remote ID is mandatory in the US/EU since 2023 — absence flags high risk', 'RTL-SDR catches DJI/FPV video links on 2.4 GHz if HackRF unavailable', 'Risk HIGH = no Remote ID or non-compliant; MEDIUM = multi-vector or RSSI anomaly', 'Map markers appear only for contacts with GPS coordinates from Remote ID'] },
|
||||
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||
|
||||
@@ -330,6 +330,11 @@ const CommandPalette = (function() {
|
||||
}
|
||||
|
||||
function goToMode(mode) {
|
||||
if (mode === 'satellite') {
|
||||
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
||||
welcome.style.display = 'none';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Shared observer location helper for map-based modules.
|
||||
// Default: shared location enabled unless explicitly disabled via config.
|
||||
window.ObserverLocation = (function() {
|
||||
const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
|
||||
? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
|
||||
: { lat: 51.5074, lon: -0.1278 };
|
||||
const SHARED_KEY = 'observerLocation';
|
||||
const AIS_KEY = 'ais_observerLocation';
|
||||
const LEGACY_LAT_KEY = 'observerLat';
|
||||
@@ -21,6 +18,9 @@ window.ObserverLocation = (function() {
|
||||
return { lat: latNum, lon: lonNum };
|
||||
}
|
||||
|
||||
const DEFAULT_LOCATION = normalize(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON)
|
||||
|| { lat: 51.5074, lon: -0.1278 };
|
||||
|
||||
function parseLocation(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
@@ -39,7 +39,7 @@ window.ObserverLocation = (function() {
|
||||
function readLegacyLatLon() {
|
||||
const lat = localStorage.getItem(LEGACY_LAT_KEY);
|
||||
const lon = localStorage.getItem(LEGACY_LON_KEY);
|
||||
if (!lat || !lon) return null;
|
||||
if (lat === null || lon === null) return null;
|
||||
return normalize(lat, lon);
|
||||
}
|
||||
|
||||
@@ -60,11 +60,12 @@ window.ObserverLocation = (function() {
|
||||
}
|
||||
|
||||
function setShared(location, options = {}) {
|
||||
if (!location) return;
|
||||
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
|
||||
const normalized = location ? normalize(location.lat, location.lon) : null;
|
||||
if (!normalized) return;
|
||||
localStorage.setItem(SHARED_KEY, JSON.stringify(normalized));
|
||||
if (options.updateLegacy !== false) {
|
||||
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +85,17 @@ window.ObserverLocation = (function() {
|
||||
}
|
||||
|
||||
function setForModule(moduleKey, location, options = {}) {
|
||||
if (!location) return;
|
||||
const normalized = location ? normalize(location.lat, location.lon) : null;
|
||||
if (!normalized) return;
|
||||
if (isSharedEnabled()) {
|
||||
setShared(location, options);
|
||||
setShared(normalized, options);
|
||||
return;
|
||||
}
|
||||
if (moduleKey) {
|
||||
localStorage.setItem(moduleKey, JSON.stringify(location));
|
||||
localStorage.setItem(moduleKey, JSON.stringify(normalized));
|
||||
} else if (options.fallbackToLatLon) {
|
||||
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
||||
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
|
||||
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,9 +137,3 @@ const RecordingUI = (function() {
|
||||
openReplay,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.init();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
@@ -896,23 +952,26 @@ function loadObserverLocation() {
|
||||
lon = shared.lon.toString();
|
||||
}
|
||||
|
||||
const hasLat = lat !== undefined && lat !== null && lat !== '';
|
||||
const hasLon = lon !== undefined && lon !== null && lon !== '';
|
||||
|
||||
const latInput = document.getElementById('observerLatInput');
|
||||
const lonInput = document.getElementById('observerLonInput');
|
||||
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
||||
const currentLonDisplay = document.getElementById('currentLonDisplay');
|
||||
|
||||
if (latInput && lat) latInput.value = lat;
|
||||
if (lonInput && lon) lonInput.value = lon;
|
||||
if (latInput && hasLat) latInput.value = lat;
|
||||
if (lonInput && hasLon) lonInput.value = lon;
|
||||
|
||||
if (currentLatDisplay) {
|
||||
currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
|
||||
currentLatDisplay.textContent = hasLat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
|
||||
}
|
||||
if (currentLonDisplay) {
|
||||
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
|
||||
currentLonDisplay.textContent = hasLon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
|
||||
}
|
||||
|
||||
// Sync dashboard-specific location keys for backward compatibility
|
||||
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
|
||||
if (hasLat && hasLon) {
|
||||
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||
if (!localStorage.getItem('observerLocation')) {
|
||||
localStorage.setItem('observerLocation', locationObj);
|
||||
@@ -1011,9 +1070,9 @@ function detectLocationGPS(btn) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save observer location to localStorage
|
||||
* Save observer location to localStorage and persist defaults to .env
|
||||
*/
|
||||
function saveObserverLocation() {
|
||||
async function saveObserverLocation() {
|
||||
const latInput = document.getElementById('observerLatInput');
|
||||
const lonInput = document.getElementById('observerLonInput');
|
||||
|
||||
@@ -1056,19 +1115,48 @@ function saveObserverLocation() {
|
||||
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
|
||||
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Location', 'Observer location saved');
|
||||
}
|
||||
|
||||
if (window.observerLocation) {
|
||||
window.observerLocation.lat = lat;
|
||||
window.observerLocation.lon = lon;
|
||||
}
|
||||
|
||||
let notificationMessage = 'Observer location saved';
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/observer-location', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ lat, lon }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.status === 'error') {
|
||||
throw new Error(data.message || 'Failed to save observer location to .env');
|
||||
}
|
||||
window.INTERCEPT_DEFAULT_LAT = lat;
|
||||
window.INTERCEPT_DEFAULT_LON = lon;
|
||||
notificationMessage = 'Observer location saved to settings and .env';
|
||||
} catch (error) {
|
||||
notificationMessage = `Observer location saved for this browser, but .env update failed: ${error.message}`;
|
||||
}
|
||||
|
||||
// Refresh SSTV ISS schedule if available
|
||||
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
|
||||
SSTV.loadIssSchedule();
|
||||
}
|
||||
|
||||
// Update APRS user location if function is available
|
||||
if (typeof updateAprsUserLocation === 'function') {
|
||||
updateAprsUserLocation({ latitude: lat, longitude: lon });
|
||||
}
|
||||
|
||||
// Notify all listeners (any mode can subscribe)
|
||||
window.dispatchEvent(new CustomEvent('observer-location-changed', { detail: { lat, lon } }));
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Location', notificationMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1260,11 +1348,11 @@ function switchSettingsTab(tabName) {
|
||||
} else if (tabName === 'alerts') {
|
||||
loadVoiceAlertConfig();
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.loadFeed();
|
||||
AlertCenter.init();
|
||||
}
|
||||
} else if (tabName === 'recording') {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.refresh();
|
||||
RecordingUI.init();
|
||||
}
|
||||
} else if (tabName === 'apikeys') {
|
||||
loadApiKeyStatus();
|
||||
|
||||
+41
-23
@@ -2,12 +2,13 @@
|
||||
* Updater Module - GitHub update checking and notification system
|
||||
*/
|
||||
|
||||
const Updater = {
|
||||
// State
|
||||
_checkInterval: null,
|
||||
_toastElement: null,
|
||||
_modalElement: null,
|
||||
_updateData: null,
|
||||
const Updater = {
|
||||
// State
|
||||
_checkInterval: null,
|
||||
_startupCheckTimer: null,
|
||||
_toastElement: null,
|
||||
_modalElement: null,
|
||||
_updateData: null,
|
||||
|
||||
// Configuration
|
||||
CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds
|
||||
@@ -15,18 +16,31 @@ const Updater = {
|
||||
/**
|
||||
* Initialize the updater module
|
||||
*/
|
||||
init() {
|
||||
// Create toast container if it doesn't exist
|
||||
this._ensureToastContainer();
|
||||
|
||||
// Check for updates on page load
|
||||
this.checkForUpdates();
|
||||
|
||||
// Set up periodic checks
|
||||
this._checkInterval = setInterval(() => {
|
||||
this.checkForUpdates();
|
||||
}, this.CHECK_INTERVAL_MS);
|
||||
},
|
||||
init() {
|
||||
// Create toast container if it doesn't exist
|
||||
this._ensureToastContainer();
|
||||
|
||||
const enabled = localStorage.getItem('intercept_update_check_enabled') !== 'false';
|
||||
if (!enabled) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer the first check so the active dashboard can finish loading first.
|
||||
if (!this._startupCheckTimer) {
|
||||
this._startupCheckTimer = setTimeout(() => {
|
||||
this._startupCheckTimer = null;
|
||||
this.checkForUpdates();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
// Set up periodic checks
|
||||
if (!this._checkInterval) {
|
||||
this._checkInterval = setInterval(() => {
|
||||
this.checkForUpdates();
|
||||
}, this.CHECK_INTERVAL_MS);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure toast container exists in DOM
|
||||
@@ -505,11 +519,15 @@ const Updater = {
|
||||
/**
|
||||
* Clean up on page unload
|
||||
*/
|
||||
destroy() {
|
||||
if (this._checkInterval) {
|
||||
clearInterval(this._checkInterval);
|
||||
this._checkInterval = null;
|
||||
}
|
||||
destroy() {
|
||||
if (this._startupCheckTimer) {
|
||||
clearTimeout(this._startupCheckTimer);
|
||||
this._startupCheckTimer = null;
|
||||
}
|
||||
if (this._checkInterval) {
|
||||
clearInterval(this._checkInterval);
|
||||
this._checkInterval = null;
|
||||
}
|
||||
this.hideToast();
|
||||
this.hideModal();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,114 @@ function isValidChannel(ch) {
|
||||
|
||||
// ============== TIME FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Global time preferences — timezone and 12h/24h format.
|
||||
* Stored in localStorage, used by all modes.
|
||||
*/
|
||||
const InterceptTime = (function() {
|
||||
const TZ_MAP = {
|
||||
'UTC': 'UTC',
|
||||
'local': undefined,
|
||||
'US/Eastern': 'America/New_York',
|
||||
'US/Central': 'America/Chicago',
|
||||
'US/Mountain': 'America/Denver',
|
||||
'US/Pacific': 'America/Los_Angeles',
|
||||
};
|
||||
|
||||
const TZ_LABELS = {
|
||||
'UTC': 'UTC',
|
||||
'local': '',
|
||||
'US/Eastern': 'ET',
|
||||
'US/Central': 'CT',
|
||||
'US/Mountain': 'MT',
|
||||
'US/Pacific': 'PT',
|
||||
};
|
||||
|
||||
let _timezone = localStorage.getItem('interceptTimezone') || 'US/Eastern';
|
||||
let _hour12 = (localStorage.getItem('interceptHour12') || 'true') === 'true';
|
||||
const _listeners = [];
|
||||
|
||||
function getTimezone() { return _timezone; }
|
||||
function getHour12() { return _hour12; }
|
||||
function getIANA() { return TZ_MAP[_timezone]; }
|
||||
function getLabel() { return TZ_LABELS[_timezone] || ''; }
|
||||
|
||||
function setTimezone(tz) {
|
||||
if (!TZ_MAP.hasOwnProperty(tz)) return;
|
||||
_timezone = tz;
|
||||
localStorage.setItem('interceptTimezone', tz);
|
||||
// Migrate weather-sat specific key
|
||||
localStorage.setItem('wxsatTimezone', tz);
|
||||
_notify();
|
||||
}
|
||||
|
||||
function setHour12(val) {
|
||||
_hour12 = !!val;
|
||||
localStorage.setItem('interceptHour12', _hour12 ? 'true' : 'false');
|
||||
_notify();
|
||||
}
|
||||
|
||||
function onChange(fn) { _listeners.push(fn); }
|
||||
function _notify() { _listeners.forEach(fn => { try { fn(); } catch(e) { console.error(e); } }); }
|
||||
|
||||
/**
|
||||
* Format a Date or ISO string for the global timezone.
|
||||
* @param {Date|string} input - Date object or ISO string
|
||||
* @param {object} [extraOpts] - Additional Intl.DateTimeFormat options
|
||||
* @returns {string}
|
||||
*/
|
||||
function format(input, extraOpts) {
|
||||
if (!input) return '--';
|
||||
try {
|
||||
const date = typeof input === 'string' ? new Date(input) : input;
|
||||
if (isNaN(date.getTime())) return typeof input === 'string' ? input : '--';
|
||||
const opts = { hour12: _hour12, ...extraOpts };
|
||||
const iana = getIANA();
|
||||
if (iana) opts.timeZone = iana;
|
||||
return date.toLocaleString(undefined, opts);
|
||||
} catch { return typeof input === 'string' ? input : '--'; }
|
||||
}
|
||||
|
||||
/** HH:MM (or h:MM AM/PM) */
|
||||
function shortTime(input) {
|
||||
return format(input, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** HH:MM:SS */
|
||||
function fullTime(input) {
|
||||
return format(input, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
/** Mon 25, 14:30 (or 2:30 PM) */
|
||||
function dateTime(input) {
|
||||
return format(input, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** Mon 25, 2026 */
|
||||
function dateOnly(input) {
|
||||
const iana = getIANA();
|
||||
const opts = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
if (iana) opts.timeZone = iana;
|
||||
try {
|
||||
const date = typeof input === 'string' ? new Date(input) : input;
|
||||
return date.toLocaleDateString(undefined, opts);
|
||||
} catch { return '--'; }
|
||||
}
|
||||
|
||||
/** Short label like " ET" or " UTC" for appending to times */
|
||||
function tzSuffix() {
|
||||
const l = getLabel();
|
||||
return l ? ' ' + l : '';
|
||||
}
|
||||
|
||||
return {
|
||||
getTimezone, getHour12, getIANA, getLabel,
|
||||
setTimezone, setHour12, onChange,
|
||||
format, shortTime, fullTime, dateTime, dateOnly, tzSuffix,
|
||||
TZ_MAP, TZ_LABELS,
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Get relative time string from timestamp
|
||||
* @param {string} timestamp - Time string in HH:MM:SS format
|
||||
|
||||
@@ -8,6 +8,7 @@ const VoiceAlerts = (function () {
|
||||
let _queue = [];
|
||||
let _speaking = false;
|
||||
let _sources = {};
|
||||
let _streamStartTimer = null;
|
||||
const STORAGE_KEY = 'intercept-voice-muted';
|
||||
const CONFIG_KEY = 'intercept-voice-config';
|
||||
const RATE_MIN = 0.5;
|
||||
@@ -132,7 +133,12 @@ const VoiceAlerts = (function () {
|
||||
}
|
||||
|
||||
function _startStreams() {
|
||||
if (_streamStartTimer) {
|
||||
clearTimeout(_streamStartTimer);
|
||||
_streamStartTimer = null;
|
||||
}
|
||||
if (!_enabled) return;
|
||||
if (Object.keys(_sources).length > 0) return;
|
||||
|
||||
// Pager stream
|
||||
if (_config.streams.pager) {
|
||||
@@ -173,17 +179,32 @@ const VoiceAlerts = (function () {
|
||||
}
|
||||
|
||||
function _stopStreams() {
|
||||
if (_streamStartTimer) {
|
||||
clearTimeout(_streamStartTimer);
|
||||
_streamStartTimer = null;
|
||||
}
|
||||
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||
_sources = {};
|
||||
}
|
||||
|
||||
function init() {
|
||||
function init(options) {
|
||||
const opts = options || {};
|
||||
_loadConfig();
|
||||
if (_isSpeechSupported()) {
|
||||
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||
speechSynthesis.getVoices();
|
||||
}
|
||||
_startStreams();
|
||||
if (opts.startStreams !== false) {
|
||||
_startStreams();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamStart(delayMs) {
|
||||
if (_streamStartTimer || Object.keys(_sources).length > 0 || !_enabled) return;
|
||||
_streamStartTimer = window.setTimeout(() => {
|
||||
_streamStartTimer = null;
|
||||
_startStreams();
|
||||
}, Number(delayMs) > 0 ? Number(delayMs) : 20000);
|
||||
}
|
||||
|
||||
function setEnabled(val) {
|
||||
@@ -255,7 +276,7 @@ const VoiceAlerts = (function () {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
return { init, scheduleStreamStart, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
})();
|
||||
|
||||
window.VoiceAlerts = VoiceAlerts;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
var DroneMode = (function () {
|
||||
'use strict';
|
||||
|
||||
var _sse = null;
|
||||
var _map = null;
|
||||
var _markers = {};
|
||||
var _trails = {};
|
||||
var _initialized = false;
|
||||
|
||||
function init() {
|
||||
if (_initialized) {
|
||||
_refreshStatus();
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
document.getElementById('droneStartBtn')?.addEventListener('click', _start);
|
||||
document.getElementById('droneStopBtn')?.addEventListener('click', _stop);
|
||||
_initMap();
|
||||
_connectSSE();
|
||||
_refreshStatus();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
_disconnectSSE();
|
||||
if (_map) {
|
||||
if (typeof Settings !== 'undefined' && Settings.unregisterMap) Settings.unregisterMap(_map);
|
||||
_map.remove();
|
||||
_map = null;
|
||||
}
|
||||
_markers = {};
|
||||
_trails = {};
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
function invalidateMap() {
|
||||
if (_map) _map.invalidateSize();
|
||||
}
|
||||
|
||||
function _initMap() {
|
||||
if (_map) return;
|
||||
var mapEl = document.getElementById('droneMainMap');
|
||||
if (!mapEl || typeof L === 'undefined') return;
|
||||
_map = L.map('droneMainMap', { zoomControl: true }).setView([20, 0], 2);
|
||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||
Settings.createTileLayer().addTo(_map);
|
||||
Settings.registerMap(_map);
|
||||
} else {
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(_map);
|
||||
}
|
||||
}
|
||||
|
||||
function _connectSSE() {
|
||||
if (_sse) return;
|
||||
_sse = new EventSource('/drone/stream');
|
||||
_sse.addEventListener('message', function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'contact') _handleContact(msg.data);
|
||||
} catch (_) {}
|
||||
});
|
||||
_sse.onerror = function () {
|
||||
_sse.close();
|
||||
_sse = null;
|
||||
setTimeout(_connectSSE, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function _disconnectSSE() {
|
||||
if (_sse) { _sse.close(); _sse = null; }
|
||||
}
|
||||
|
||||
function _handleContact(contact) {
|
||||
_upsertCard(contact);
|
||||
if (contact.position) _upsertMapMarker(contact);
|
||||
_updateStats();
|
||||
}
|
||||
|
||||
function _upsertCard(contact) {
|
||||
var listEl = document.getElementById('droneContactList');
|
||||
var emptyEl = document.getElementById('droneContactEmpty');
|
||||
if (!listEl) return;
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
var card = document.getElementById('drone-card-' + contact.id);
|
||||
if (!card) {
|
||||
card = document.createElement('div');
|
||||
card.id = 'drone-card-' + contact.id;
|
||||
card.className = 'drone-contact-card';
|
||||
card.addEventListener('click', function () { _focusContact(contact.id); });
|
||||
listEl.prepend(card);
|
||||
}
|
||||
card.className = 'drone-contact-card ' + contact.risk_level + '-risk';
|
||||
var complianceLabel = contact.compliant
|
||||
? '<span class="drone-compliance-badge compliant">Remote ID</span>'
|
||||
: '<span class="drone-compliance-badge non-compliant">No Remote ID</span>';
|
||||
var vectors = (contact.detection_vectors || []).map(function (v) {
|
||||
return '<span class="drone-vector-pill active">' + v + '</span>';
|
||||
}).join('');
|
||||
var alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + ' m' : '—';
|
||||
var spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + ' m/s' : '—';
|
||||
card.innerHTML = [
|
||||
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">',
|
||||
' <span style="font-family:var(--font-mono); font-size:11px; color:var(--accent-cyan);">' + (contact.serial_number || contact.id) + '</span>',
|
||||
' ' + complianceLabel,
|
||||
'</div>',
|
||||
'<div class="drone-vector-pills" style="margin-bottom:6px;">' + vectors + '</div>',
|
||||
'<div style="font-size:10px; color:var(--text-dim);">Alt: ' + alt + ' Speed: ' + spd + '</div>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function _upsertMapMarker(contact) {
|
||||
if (!_map) return;
|
||||
var lat = contact.position[0];
|
||||
var lon = contact.position[1];
|
||||
if (_markers[contact.id]) {
|
||||
_markers[contact.id].setLatLng([lat, lon]);
|
||||
} else {
|
||||
var color = contact.risk_level === 'high' ? 'var(--accent-red)' :
|
||||
contact.risk_level === 'medium' ? 'var(--accent-yellow)' :
|
||||
'var(--accent-cyan)';
|
||||
var icon = L.divIcon({
|
||||
className: 'drone-map-icon' + (contact.risk_level === 'high' ? ' drone-marker-high-risk' : ''),
|
||||
html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid #fff;"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
});
|
||||
_markers[contact.id] = L.marker([lat, lon], { icon: icon })
|
||||
.addTo(_map)
|
||||
.bindPopup('<b>' + (contact.serial_number || contact.id) + '</b><br>Risk: ' + contact.risk_level);
|
||||
}
|
||||
var trailPoints = (contact.position_history || []).map(function (p) {
|
||||
return [p.lat, p.lon];
|
||||
});
|
||||
if (_trails[contact.id]) {
|
||||
_trails[contact.id].setLatLngs(trailPoints);
|
||||
} else if (trailPoints.length > 1) {
|
||||
_trails[contact.id] = L.polyline(trailPoints, {
|
||||
color: contact.risk_level === 'high' ? '#ff4444' : '#00ccff',
|
||||
weight: 1.5,
|
||||
opacity: 0.6,
|
||||
}).addTo(_map);
|
||||
}
|
||||
}
|
||||
|
||||
function _focusContact(contactId) {
|
||||
if (_map && _markers[contactId]) {
|
||||
_map.panTo(_markers[contactId].getLatLng());
|
||||
_markers[contactId].openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
function _updateStats() {
|
||||
fetch('/drone/contacts')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (contacts) {
|
||||
var nonCompliant = contacts.filter(function (c) { return !c.compliant; }).length;
|
||||
var highRisk = contacts.filter(function (c) { return c.risk_level === 'high'; }).length;
|
||||
var set = function (id, val) { var el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
set('droneContactCount', contacts.length);
|
||||
set('droneNonCompliantCount', nonCompliant);
|
||||
set('droneVsContacts', contacts.length);
|
||||
set('droneVsNonCompliant', nonCompliant);
|
||||
set('droneVsHighRisk', highRisk);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _refreshStatus() {
|
||||
fetch('/drone/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
_setRunningUI(data.running);
|
||||
_updateVectorPills(data.vectors || []);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _start() {
|
||||
var ifaceVal = document.getElementById('droneWifiIface')?.value || '';
|
||||
var iface = ifaceVal || null;
|
||||
var rtlVal = document.getElementById('droneRtlIndex')?.value;
|
||||
var rtlIndex = rtlVal !== '' && rtlVal != null ? parseInt(rtlVal, 10) : 0;
|
||||
var useHackrf = document.getElementById('droneUseHackrf')?.checked ?? true;
|
||||
fetch('/drone/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wifi_iface: iface, rtl_sdr_index: rtlIndex, use_hackrf: useHackrf }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () { _setRunningUI(true); _refreshStatus(); })
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _stop() {
|
||||
fetch('/drone/stop', { method: 'POST' })
|
||||
.then(function () { _setRunningUI(false); _refreshStatus(); })
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _setRunningUI(running) {
|
||||
var startBtn = document.getElementById('droneStartBtn');
|
||||
var stopBtn = document.getElementById('droneStopBtn');
|
||||
var statusEl = document.getElementById('droneStatusText');
|
||||
if (startBtn) startBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
if (statusEl) {
|
||||
statusEl.textContent = running ? 'Active' : 'Standby';
|
||||
statusEl.style.color = running ? 'var(--accent-green)' : 'var(--accent-yellow)';
|
||||
}
|
||||
// Sync global state for switchMode stop phase
|
||||
if (typeof isDroneRunning !== 'undefined') isDroneRunning = running;
|
||||
}
|
||||
|
||||
function _updateVectorPills(activeVectors) {
|
||||
var pillMap = {
|
||||
'REMOTE_ID': 'dronePillRemoteId',
|
||||
'RTL433': 'dronePill433',
|
||||
'HACKRF': 'dronePillHackrf',
|
||||
};
|
||||
Object.keys(pillMap).forEach(function (key) {
|
||||
var el = document.getElementById(pillMap[key]);
|
||||
if (el) el.classList.toggle('active', activeVectors.some(function (v) { return v.includes(key); }));
|
||||
});
|
||||
}
|
||||
|
||||
return { init: init, destroy: destroy, invalidateMap: invalidateMap };
|
||||
})();
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Ground Station Live Waterfall — Phase 5
|
||||
*
|
||||
* Subscribes to /ws/satellite_waterfall, receives binary frames in the same
|
||||
* wire format as the main listening-post waterfall, and renders them onto the
|
||||
* <canvas id="gs-waterfall"> element in satellite_dashboard.html.
|
||||
*
|
||||
* Wire frame format (matches utils/waterfall_fft.build_binary_frame):
|
||||
* [uint8 msg_type=0x01]
|
||||
* [float32 start_freq_mhz]
|
||||
* [float32 end_freq_mhz]
|
||||
* [uint16 bin_count]
|
||||
* [uint8[] bins] — 0=noise floor, 255=strongest signal
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const CANVAS_ID = 'gs-waterfall';
|
||||
const ROW_HEIGHT = 2; // px per waterfall row
|
||||
const SCROLL_STEP = ROW_HEIGHT;
|
||||
|
||||
let _ws = null;
|
||||
let _canvas = null;
|
||||
let _ctx = null;
|
||||
let _offscreen = null; // offscreen ImageData buffer
|
||||
let _reconnectTimer = null;
|
||||
let _centerMhz = 0;
|
||||
let _spanMhz = 0;
|
||||
let _connected = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Colour palette — 256-entry RGB array (matches listening-post waterfall)
|
||||
// -----------------------------------------------------------------------
|
||||
const _palette = _buildPalette();
|
||||
|
||||
function _buildPalette() {
|
||||
const p = new Uint8Array(256 * 3);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let r, g, b;
|
||||
if (i < 64) {
|
||||
// black → dark blue
|
||||
r = 0; g = 0; b = Math.round(i * 2);
|
||||
} else if (i < 128) {
|
||||
// dark blue → cyan
|
||||
const t = (i - 64) / 64;
|
||||
r = 0; g = Math.round(t * 200); b = Math.round(128 + t * 127);
|
||||
} else if (i < 192) {
|
||||
// cyan → yellow
|
||||
const t = (i - 128) / 64;
|
||||
r = Math.round(t * 255); g = 200; b = Math.round(255 - t * 255);
|
||||
} else {
|
||||
// yellow → white
|
||||
const t = (i - 192) / 64;
|
||||
r = 255; g = 200; b = Math.round(t * 255);
|
||||
}
|
||||
p[i * 3] = r; p[i * 3 + 1] = g; p[i * 3 + 2] = b;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
window.GroundStationWaterfall = {
|
||||
init,
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected: () => _connected,
|
||||
setCenterFreq: (mhz, span) => { _centerMhz = mhz; _spanMhz = span; },
|
||||
};
|
||||
|
||||
function init() {
|
||||
_canvas = document.getElementById(CANVAS_ID);
|
||||
if (!_canvas) return;
|
||||
_ctx = _canvas.getContext('2d');
|
||||
_resizeCanvas();
|
||||
window.addEventListener('resize', _resizeCanvas);
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (_ws && (_ws.readyState === WebSocket.CONNECTING || _ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
if (_reconnectTimer) {
|
||||
clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/ws/satellite_waterfall`;
|
||||
|
||||
try {
|
||||
_ws = new WebSocket(url);
|
||||
_ws.binaryType = 'arraybuffer';
|
||||
|
||||
_ws.onopen = () => {
|
||||
_connected = true;
|
||||
_updateStatus('LIVE');
|
||||
console.log('[GS Waterfall] WebSocket connected');
|
||||
};
|
||||
|
||||
_ws.onmessage = (evt) => {
|
||||
if (evt.data instanceof ArrayBuffer) {
|
||||
_handleFrame(evt.data);
|
||||
}
|
||||
};
|
||||
|
||||
_ws.onclose = () => {
|
||||
_connected = false;
|
||||
_updateStatus('DISCONNECTED');
|
||||
_scheduleReconnect();
|
||||
};
|
||||
|
||||
_ws.onerror = (e) => {
|
||||
console.warn('[GS Waterfall] WebSocket error', e);
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[GS Waterfall] Failed to create WebSocket', e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
||||
if (_ws) { _ws.close(); _ws = null; }
|
||||
_connected = false;
|
||||
_updateStatus('STOPPED');
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function _handleFrame(buf) {
|
||||
const view = new DataView(buf);
|
||||
if (buf.byteLength < 11) return;
|
||||
|
||||
const msgType = view.getUint8(0);
|
||||
if (msgType !== 0x01) return;
|
||||
|
||||
// const startFreq = view.getFloat32(1, true); // little-endian
|
||||
// const endFreq = view.getFloat32(5, true);
|
||||
const binCount = view.getUint16(9, true);
|
||||
if (buf.byteLength < 11 + binCount) return;
|
||||
|
||||
const bins = new Uint8Array(buf, 11, binCount);
|
||||
|
||||
if (!_canvas || !_ctx) return;
|
||||
|
||||
const W = _canvas.width;
|
||||
const H = _canvas.height;
|
||||
|
||||
// Scroll existing image up by ROW_HEIGHT pixels
|
||||
if (!_offscreen || _offscreen.width !== W || _offscreen.height !== H) {
|
||||
_offscreen = _ctx.getImageData(0, 0, W, H);
|
||||
} else {
|
||||
_offscreen = _ctx.getImageData(0, 0, W, H);
|
||||
}
|
||||
|
||||
// Shift rows up by ROW_HEIGHT
|
||||
const data = _offscreen.data;
|
||||
const rowBytes = W * 4;
|
||||
data.copyWithin(0, SCROLL_STEP * rowBytes);
|
||||
|
||||
// Write new row(s) at the bottom
|
||||
const bottom = H - ROW_HEIGHT;
|
||||
for (let row = 0; row < ROW_HEIGHT; row++) {
|
||||
const rowStart = (bottom + row) * rowBytes;
|
||||
for (let x = 0; x < W; x++) {
|
||||
const binIdx = Math.floor((x / W) * binCount);
|
||||
const val = bins[Math.min(binIdx, binCount - 1)];
|
||||
const pi = val * 3;
|
||||
const di = rowStart + x * 4;
|
||||
data[di] = _palette[pi];
|
||||
data[di + 1] = _palette[pi + 1];
|
||||
data[di + 2] = _palette[pi + 2];
|
||||
data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
_ctx.putImageData(_offscreen, 0, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function _resizeCanvas() {
|
||||
if (!_canvas) return;
|
||||
const container = _canvas.parentElement;
|
||||
if (container) {
|
||||
_canvas.width = container.clientWidth || 400;
|
||||
_canvas.height = container.clientHeight || 200;
|
||||
}
|
||||
_offscreen = null;
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
function _drawPlaceholder() {
|
||||
if (!_ctx || !_canvas) return;
|
||||
_ctx.fillStyle = '#000a14';
|
||||
_ctx.fillRect(0, 0, _canvas.width, _canvas.height);
|
||||
_ctx.fillStyle = 'rgba(0,212,255,0.3)';
|
||||
_ctx.font = '12px monospace';
|
||||
_ctx.textAlign = 'center';
|
||||
_ctx.fillText('AWAITING SATELLITE PASS', _canvas.width / 2, _canvas.height / 2);
|
||||
_ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
function _updateStatus(text) {
|
||||
const el = document.getElementById('gsWaterfallStatus');
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function _scheduleReconnect(delayMs = 5000) {
|
||||
if (_reconnectTimer) return;
|
||||
_reconnectTimer = setTimeout(() => {
|
||||
_reconnectTimer = null;
|
||||
connect();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// Auto-init when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Meshcore Mode
|
||||
* Handles connection, live SSE streaming, message feed, map, telemetry,
|
||||
* repeater management, contacts, and traceroute visualization.
|
||||
*/
|
||||
const MeshCore = (function () {
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
let _transport = 'serial';
|
||||
let _eventSource = null;
|
||||
let _map = null;
|
||||
let _markers = {};
|
||||
let _telemetryChart = null;
|
||||
let _connected = false;
|
||||
let _nodeCount = 0;
|
||||
let _msgCount = 0;
|
||||
|
||||
// ── Init / Destroy ─────────────────────────────────────────────────────
|
||||
function init() {
|
||||
_loadPorts();
|
||||
_checkStatus();
|
||||
_initMap();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (_eventSource) { _eventSource.close(); _eventSource = null; }
|
||||
if (_map) { _map.remove(); _map = null; _markers = {}; }
|
||||
if (_telemetryChart) { _telemetryChart.destroy(); _telemetryChart = null; }
|
||||
_connected = false;
|
||||
_nodeCount = 0;
|
||||
_msgCount = 0;
|
||||
}
|
||||
|
||||
function invalidateMap() {
|
||||
if (_map) _map.invalidateSize();
|
||||
}
|
||||
|
||||
// ── Status ─────────────────────────────────────────────────────────────
|
||||
async function _checkStatus() {
|
||||
try {
|
||||
const r = await fetch('/meshcore/status');
|
||||
const d = await r.json();
|
||||
_updateStatusUI(d.state || 'disconnected', d.message);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function _updateStatusUI(state, message) {
|
||||
const dot = document.getElementById('meshcoreStatusDot');
|
||||
const txt = document.getElementById('meshcoreStatusText');
|
||||
const connectBtn = document.getElementById('meshcoreConnectBtn');
|
||||
const disconnectBtn = document.getElementById('meshcoreDisconnectBtn');
|
||||
if (!dot) return;
|
||||
|
||||
dot.className = 'meshcore-status-dot ' + state;
|
||||
const labels = { connected: 'Connected', connecting: 'Connecting…', error: 'Error', disconnected: 'Disconnected', unavailable: 'Not available' };
|
||||
txt.textContent = message || labels[state] || state;
|
||||
|
||||
_connected = state === 'connected';
|
||||
if (connectBtn) connectBtn.disabled = state === 'connecting' || _connected;
|
||||
if (disconnectBtn) disconnectBtn.disabled = state !== 'connecting' && !_connected;
|
||||
|
||||
if (_connected && !_eventSource) _startSSE();
|
||||
if (!_connected && _eventSource) { _eventSource.close(); _eventSource = null; }
|
||||
}
|
||||
|
||||
// ── Transport selector ─────────────────────────────────────────────────
|
||||
function selectTransport(t) {
|
||||
_transport = t;
|
||||
document.querySelectorAll('.meshcore-transport-tab').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.transport === t);
|
||||
});
|
||||
document.getElementById('meshcoreSerialConfig').style.display = t === 'serial' ? '' : 'none';
|
||||
document.getElementById('meshcoreTcpConfig').style.display = t === 'tcp' ? '' : 'none';
|
||||
document.getElementById('meshcoreBleConfig').style.display = t === 'ble' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── Connect / Disconnect ───────────────────────────────────────────────
|
||||
async function connect() {
|
||||
let body = { transport: _transport };
|
||||
if (_transport === 'serial') {
|
||||
body.port = document.getElementById('meshcorePortSelect').value || null;
|
||||
} else if (_transport === 'tcp') {
|
||||
body.host = document.getElementById('meshcoreTcpHost').value;
|
||||
body.port = parseInt(document.getElementById('meshcoreTcpPort').value, 10);
|
||||
} else if (_transport === 'ble') {
|
||||
body.address = document.getElementById('meshcoreBleSelect').value || null;
|
||||
}
|
||||
try {
|
||||
_updateStatusUI('connecting');
|
||||
await fetch('/meshcore/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
_pollUntilConnected(0);
|
||||
} catch (e) {
|
||||
_updateStatusUI('error', 'Connection failed');
|
||||
console.error('Connect failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _pollUntilConnected(attempts) {
|
||||
if (_connected) return;
|
||||
if (attempts > 45) {
|
||||
// Backend retry window (5+15+45s) has elapsed — give up
|
||||
_updateStatusUI('error', 'Connection timed out');
|
||||
return;
|
||||
}
|
||||
setTimeout(async () => {
|
||||
await _checkStatus();
|
||||
if (!_connected) _pollUntilConnected(attempts + 1);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
try {
|
||||
await fetch('/meshcore/disconnect', { method: 'POST' });
|
||||
_updateStatusUI('disconnected');
|
||||
} catch (e) { console.error('Disconnect failed:', e); }
|
||||
}
|
||||
|
||||
// ── Port / BLE discovery ───────────────────────────────────────────────
|
||||
async function _loadPorts() {
|
||||
try {
|
||||
const r = await fetch('/meshcore/ports');
|
||||
const d = await r.json();
|
||||
const sel = document.getElementById('meshcorePortSelect');
|
||||
if (!sel) return;
|
||||
const current = sel.value;
|
||||
sel.innerHTML = '<option value="">Auto-detect</option>';
|
||||
(d.ports || []).forEach(p => {
|
||||
const o = document.createElement('option');
|
||||
o.value = p; o.textContent = p;
|
||||
if (p === current) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function scanBle() {
|
||||
const btn = document.querySelector('[onclick="MeshCore.scanBle()"]');
|
||||
const sel = document.getElementById('meshcoreBleSelect');
|
||||
if (btn) { btn.textContent = 'Scanning…'; btn.disabled = true; }
|
||||
if (sel) sel.innerHTML = '<option value="">Scanning…</option>';
|
||||
try {
|
||||
const r = await fetch('/meshcore/ble/scan');
|
||||
const d = await r.json();
|
||||
if (!sel) return;
|
||||
const devices = d.devices || [];
|
||||
if (!devices.length) {
|
||||
sel.innerHTML = '<option value="">No devices found</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = '<option value="">Select device…</option>';
|
||||
devices.forEach(dev => {
|
||||
const o = document.createElement('option');
|
||||
o.value = dev.address;
|
||||
o.textContent = `${dev.name || 'Unknown'} (${dev.address})${dev.rssi ? ' · ' + dev.rssi + ' dBm' : ''}`;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if (devices.length === 1) sel.value = devices[0].address;
|
||||
} catch (e) {
|
||||
if (sel) sel.innerHTML = '<option value="">Scan failed — retry</option>';
|
||||
console.error('BLE scan failed:', e);
|
||||
} finally {
|
||||
if (btn) { btn.textContent = 'Scan'; btn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE Stream ─────────────────────────────────────────────────────────
|
||||
function _startSSE() {
|
||||
if (_eventSource) _eventSource.close();
|
||||
_eventSource = new EventSource('/meshcore/stream');
|
||||
_eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
_routeEvent(event);
|
||||
} catch (err) { /* ignore malformed */ }
|
||||
};
|
||||
_eventSource.onerror = () => {
|
||||
setTimeout(_checkStatus, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
function _routeEvent(event) {
|
||||
switch (event.type) {
|
||||
case 'status': _updateStatusUI(event.data.state, event.data.message); break;
|
||||
case 'message': _appendMessage(event.data); break;
|
||||
case 'node': _updateNode(event.data); break;
|
||||
case 'telemetry': _storeTelemetry(event.data); break;
|
||||
case 'traceroute': _showTraceroute(event.data); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Messages ───────────────────────────────────────────────────────────
|
||||
function _appendMessage(msg) {
|
||||
const feed = document.getElementById('meshcoreMessageFeed');
|
||||
if (!feed) return;
|
||||
const placeholder = feed.querySelector('div[style*="padding:24px"]');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'meshcore-message' + (msg.is_direct ? ' direct' : '') + (msg.pending ? ' pending' : '');
|
||||
el.dataset.msgId = msg.id;
|
||||
const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
|
||||
const snr = msg.snr !== null && msg.snr !== undefined ? ` · ${msg.snr} dB` : '';
|
||||
el.innerHTML = `
|
||||
<div class="meshcore-message-header">
|
||||
<span class="meshcore-message-sender">${_esc(msg.sender_id)}</span>
|
||||
<span>${_esc(msg.recipient_id)} · ${ts}${snr}</span>
|
||||
</div>
|
||||
<div class="meshcore-message-text">${_esc(msg.text)}</div>`;
|
||||
feed.appendChild(el);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
_msgCount++;
|
||||
const mc = document.getElementById('meshcoreMsgCount');
|
||||
if (mc) mc.textContent = _msgCount;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('meshcoreComposeInput');
|
||||
const recipientSel = document.getElementById('meshcoreRecipientSelect');
|
||||
const text = input ? input.value.trim() : '';
|
||||
if (!text) return;
|
||||
const recipient_id = recipientSel ? recipientSel.value : 'BROADCAST';
|
||||
|
||||
const tempId = 'pending-' + Date.now();
|
||||
_appendMessage({ id: tempId, sender_id: 'Me', recipient_id, text, timestamp: new Date().toISOString(), is_direct: recipient_id !== 'BROADCAST', snr: null, pending: true });
|
||||
if (input) input.value = '';
|
||||
|
||||
try {
|
||||
const r = await fetch('/meshcore/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, recipient_id }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json();
|
||||
_removePending(tempId);
|
||||
alert(d.error || 'Send failed');
|
||||
}
|
||||
} catch (e) {
|
||||
_removePending(tempId);
|
||||
console.error('Send failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _removePending(id) {
|
||||
const el = document.querySelector(`[data-msg-id="${id}"]`);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
// ── Nodes ──────────────────────────────────────────────────────────────
|
||||
function _updateNode(node) {
|
||||
_updateNodeSidebar(node);
|
||||
_updateMapMarker(node);
|
||||
_updateRepeaterTable(node);
|
||||
_updateTelemetryNodeSelect(node);
|
||||
_updateRecipientSelect(node);
|
||||
}
|
||||
|
||||
function _updateNodeSidebar(node) {
|
||||
const list = document.getElementById('meshcoreNodeList');
|
||||
if (!list) return;
|
||||
let el = document.getElementById('meshcore-node-' + node.node_id);
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'meshcore-node-item';
|
||||
el.id = 'meshcore-node-' + node.node_id;
|
||||
const empty = list.querySelector('.meshcore-empty');
|
||||
if (empty) empty.remove();
|
||||
list.appendChild(el);
|
||||
_nodeCount++;
|
||||
const nc = document.getElementById('meshcoreNodeCount');
|
||||
if (nc) nc.textContent = _nodeCount;
|
||||
}
|
||||
const hops = node.hops_away !== null ? `${node.hops_away}h` : '?';
|
||||
const snr = node.snr !== null ? `${node.snr}dB` : '';
|
||||
el.innerHTML = `
|
||||
<div class="meshcore-node-icon${node.is_repeater ? ' repeater' : ''}"></div>
|
||||
<div class="meshcore-node-name" title="${_esc(node.node_id)}">${_esc(node.name)}</div>
|
||||
<div class="meshcore-node-meta">${hops} ${snr}</div>`;
|
||||
}
|
||||
|
||||
function _updateRepeaterTable(node) {
|
||||
if (!node.is_repeater) return;
|
||||
const tbody = document.getElementById('meshcoreRepeaterTableBody');
|
||||
if (!tbody) return;
|
||||
let row = document.getElementById('meshcore-rptr-' + node.node_id);
|
||||
if (!row) {
|
||||
if (tbody.querySelector('td[colspan]')) tbody.innerHTML = '';
|
||||
row = document.createElement('tr');
|
||||
row.id = 'meshcore-rptr-' + node.node_id;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
const ls = node.last_seen ? new Date(node.last_seen).toLocaleTimeString() : '—';
|
||||
row.innerHTML = `<td>${_esc(node.name)}</td><td style="font-family:var(--font-mono);font-size:11px">${_esc(node.node_id)}</td><td>${node.hops_away ?? '—'}</td><td>${node.snr ?? '—'}</td><td>${node.battery_pct !== null ? node.battery_pct + '%' : '—'}</td><td>${ls}</td>`;
|
||||
}
|
||||
|
||||
function _updateTelemetryNodeSelect(node) {
|
||||
const sel = document.getElementById('meshcoreTelemetryNodeSelect');
|
||||
if (!sel || sel.querySelector(`option[value="${node.node_id}"]`)) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = node.node_id; o.textContent = node.name || node.node_id;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
|
||||
function _updateRecipientSelect(node) {
|
||||
const sel = document.getElementById('meshcoreRecipientSelect');
|
||||
if (!sel || sel.querySelector(`option[value="${node.node_id}"]`)) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = node.node_id; o.textContent = node.name || node.node_id;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
|
||||
// ── Map ────────────────────────────────────────────────────────────────
|
||||
function _initMap() {
|
||||
const container = document.getElementById('meshcoreMap');
|
||||
if (!container || _map) return;
|
||||
_map = L.map('meshcoreMap', { zoomControl: true }).setView([20, 0], 2);
|
||||
|
||||
const fallback = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© CartoDB',
|
||||
maxZoom: 19,
|
||||
}).addTo(_map);
|
||||
|
||||
if (typeof Settings !== 'undefined') {
|
||||
Promise.race([
|
||||
Settings.init(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)),
|
||||
]).then(() => {
|
||||
fallback.remove();
|
||||
Settings.createTileLayer().addTo(_map);
|
||||
Settings.registerMap(_map);
|
||||
}).catch(e => console.warn('MeshCore: Settings init failed, using fallback tiles:', e));
|
||||
}
|
||||
}
|
||||
|
||||
function _updateMapMarker(node) {
|
||||
if (node.lat === null || node.lon === null) return;
|
||||
if (!_map) return;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: node.is_repeater
|
||||
? `<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #ff9800;" title="${node.name}"></div>`
|
||||
: `<div style="width:12px;height:12px;border-radius:50%;background:#00bcd4;border:2px solid #fff;box-shadow:0 0 4px rgba(0,0,0,.4);" title="${node.name}"></div>`,
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
});
|
||||
|
||||
if (_markers[node.node_id]) {
|
||||
_markers[node.node_id].setLatLng([node.lat, node.lon]).setIcon(icon);
|
||||
} else {
|
||||
_markers[node.node_id] = L.marker([node.lat, node.lon], { icon })
|
||||
.bindPopup(`<strong>${_esc(node.name)}</strong><br>${node.node_id}<br>Hops: ${node.hops_away ?? '?'}`)
|
||||
.addTo(_map);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telemetry ──────────────────────────────────────────────────────────
|
||||
function _storeTelemetry(data) { /* SSE telemetry stored server-side; chart loads on demand */ }
|
||||
|
||||
async function loadTelemetry(nodeId) {
|
||||
if (!nodeId) return;
|
||||
try {
|
||||
const r = await fetch(`/meshcore/telemetry/${encodeURIComponent(nodeId)}`);
|
||||
const d = await r.json();
|
||||
_renderTelemetryChart(d.telemetry || []);
|
||||
} catch (e) { console.error('Telemetry load failed:', e); }
|
||||
}
|
||||
|
||||
function _renderTelemetryChart(data) {
|
||||
const ctx = document.getElementById('meshcoreTelemetryChart');
|
||||
if (!ctx) return;
|
||||
if (_telemetryChart) { _telemetryChart.destroy(); _telemetryChart = null; }
|
||||
if (!data.length) return;
|
||||
|
||||
const labels = data.map(t => new Date(t.timestamp).toLocaleTimeString());
|
||||
_telemetryChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Battery %', data: data.map(t => t.battery_pct), borderColor: '#4caf50', tension: 0.3, fill: false },
|
||||
{ label: 'Temp °C', data: data.map(t => t.temperature), borderColor: '#ff9800', tension: 0.3, fill: false, yAxisID: 'y2' },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: { min: 0, max: 100, title: { display: true, text: 'Battery %' } },
|
||||
y2: { position: 'right', title: { display: true, text: 'Temp °C' } },
|
||||
},
|
||||
plugins: { legend: { labels: { color: '#ccc' } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Traceroute ─────────────────────────────────────────────────────────
|
||||
function _showTraceroute(tr) {
|
||||
const container = document.getElementById('meshcoreTracerouteHops');
|
||||
const modal = document.getElementById('meshcoreTracerouteModal');
|
||||
if (!container || !modal) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
tr.hops.forEach((hop, i) => {
|
||||
const hopEl = document.createElement('div');
|
||||
hopEl.className = 'meshcore-hop';
|
||||
hopEl.innerHTML = `<div class="meshcore-hop-node">${_esc(hop)}</div>`;
|
||||
container.appendChild(hopEl);
|
||||
|
||||
if (i < tr.hops.length - 1) {
|
||||
const arrow = document.createElement('div');
|
||||
arrow.className = 'meshcore-hop-arrow';
|
||||
const snr = tr.snr_per_hop[i] !== undefined ? `${tr.snr_per_hop[i]} dB` : '';
|
||||
arrow.innerHTML = `<span>${snr}</span><span>→</span>`;
|
||||
container.appendChild(arrow);
|
||||
}
|
||||
});
|
||||
_openModal(modal);
|
||||
}
|
||||
|
||||
function closeTraceroute() {
|
||||
_closeModal(document.getElementById('meshcoreTracerouteModal'));
|
||||
}
|
||||
|
||||
// ── Contacts ───────────────────────────────────────────────────────────
|
||||
function showAddContact() {
|
||||
_openModal(document.getElementById('meshcoreAddContactModal'));
|
||||
}
|
||||
|
||||
function closeAddContact() {
|
||||
_closeModal(document.getElementById('meshcoreAddContactModal'));
|
||||
}
|
||||
|
||||
function _openModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.style.display = '';
|
||||
requestAnimationFrame(() => modal.classList.add('show'));
|
||||
}
|
||||
|
||||
function _closeModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
setTimeout(() => { modal.style.display = 'none'; }, 200);
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
const nodeId = document.getElementById('meshcoreContactNodeId').value.trim();
|
||||
const name = document.getElementById('meshcoreContactName').value.trim();
|
||||
const key = document.getElementById('meshcoreContactKey').value.trim();
|
||||
if (!nodeId || !name || !key) { alert('All fields required'); return; }
|
||||
|
||||
try {
|
||||
const r = await fetch('/meshcore/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ node_id: nodeId, name, public_key: key }),
|
||||
});
|
||||
if (r.ok) {
|
||||
closeAddContact();
|
||||
_refreshContacts();
|
||||
} else {
|
||||
const d = await r.json();
|
||||
alert(d.error || 'Failed to add contact');
|
||||
}
|
||||
} catch (e) { console.error('Add contact failed:', e); }
|
||||
}
|
||||
|
||||
async function _refreshContacts() {
|
||||
try {
|
||||
const r = await fetch('/meshcore/contacts');
|
||||
const d = await r.json();
|
||||
const list = document.getElementById('meshcoreContactList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
if (!d.contacts || !d.contacts.length) {
|
||||
list.innerHTML = '<div style="font-size:11px;color:var(--text-muted);">No contacts</div>';
|
||||
return;
|
||||
}
|
||||
d.contacts.forEach(c => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'meshcore-node-item';
|
||||
el.innerHTML = `
|
||||
<div class="meshcore-node-icon"></div>
|
||||
<div class="meshcore-node-name">${_esc(c.name)}</div>
|
||||
<button onclick="MeshCore.deleteContact('${_esc(c.node_id)}')" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:12px;padding:0;">✕</button>`;
|
||||
list.appendChild(el);
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function deleteContact(nodeId) {
|
||||
if (!confirm(`Remove contact ${nodeId}?`)) return;
|
||||
try {
|
||||
await fetch(`/meshcore/contacts/${encodeURIComponent(nodeId)}`, { method: 'DELETE' });
|
||||
_refreshContacts();
|
||||
} catch (e) { console.error('Delete contact failed:', e); }
|
||||
}
|
||||
|
||||
// ── Tabs ───────────────────────────────────────────────────────────────
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.meshcore-tab').forEach(t =>
|
||||
t.classList.toggle('active', t.dataset.tab === name));
|
||||
const panels = { messages: 'meshcoreTabMessages', map: 'meshcoreTabMap', repeaters: 'meshcoreTabRepeaters', telemetry: 'meshcoreTabTelemetry' };
|
||||
Object.entries(panels).forEach(([k, id]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.toggle('active', k === name);
|
||||
});
|
||||
if (name === 'map') setTimeout(() => { if (_map) _map.invalidateSize(); }, 50);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
function _esc(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
return {
|
||||
init,
|
||||
destroy,
|
||||
invalidateMap,
|
||||
connect,
|
||||
disconnect,
|
||||
selectTransport,
|
||||
scanBle,
|
||||
sendMessage,
|
||||
switchTab,
|
||||
loadTelemetry,
|
||||
showAddContact,
|
||||
closeAddContact,
|
||||
saveContact,
|
||||
deleteContact,
|
||||
closeTraceroute,
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -16,8 +16,9 @@ const Meshtastic = (function() {
|
||||
|
||||
// Map state
|
||||
let meshMap = null;
|
||||
let meshMarkers = {}; // nodeId -> marker
|
||||
let localNodeId = null;
|
||||
let meshMarkers = {}; // nodeId -> marker
|
||||
let localNodeId = null;
|
||||
let clickDelegationAttached = false;
|
||||
|
||||
/**
|
||||
* Initialize the Meshtastic mode
|
||||
@@ -32,11 +33,14 @@ const Meshtastic = (function() {
|
||||
/**
|
||||
* Setup event delegation for dynamically created elements
|
||||
*/
|
||||
function setupEventDelegation() {
|
||||
// Handle button clicks in Leaflet popups and elsewhere
|
||||
document.addEventListener('click', function(e) {
|
||||
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
|
||||
if (tracerouteBtn) {
|
||||
function setupEventDelegation() {
|
||||
if (clickDelegationAttached) return;
|
||||
clickDelegationAttached = true;
|
||||
|
||||
// Handle button clicks in Leaflet popups and elsewhere
|
||||
document.addEventListener('click', function(e) {
|
||||
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
|
||||
if (tracerouteBtn) {
|
||||
const nodeId = tracerouteBtn.dataset.nodeId;
|
||||
if (nodeId) {
|
||||
sendTraceroute(nodeId);
|
||||
|
||||
@@ -16,8 +16,13 @@ const SpaceWeather = (function () {
|
||||
let _xrayChart = null;
|
||||
|
||||
// Current image selections
|
||||
let _solarImageKey = 'sdo_193';
|
||||
let _drapFreq = 'drap_global';
|
||||
let _solarImageKey = 'sdo_193';
|
||||
let _drapFreq = 'drap_global';
|
||||
const SOLAR_IMAGE_FALLBACKS = {
|
||||
sdo_193: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
|
||||
sdo_304: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
|
||||
sdo_magnetogram: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
|
||||
};
|
||||
|
||||
/** Stable cache-bust key that rotates every 5 minutes (matches backend max-age). */
|
||||
function _cacheBust() {
|
||||
@@ -48,33 +53,35 @@ const SpaceWeather = (function () {
|
||||
_fetchData();
|
||||
}
|
||||
|
||||
function selectSolarImage(key) {
|
||||
_solarImageKey = key;
|
||||
_updateSolarImageTabs();
|
||||
const frame = document.getElementById('swSolarImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
const img = new Image();
|
||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
|
||||
img.alt = key;
|
||||
}
|
||||
}
|
||||
function selectSolarImage(key) {
|
||||
_solarImageKey = key;
|
||||
_updateSolarImageTabs();
|
||||
const frame = document.getElementById('swSolarImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
_loadImageWithFallback(
|
||||
frame,
|
||||
['/space-weather/image/' + key + '?' + _cacheBust(), _directImageUrlForKey(key)],
|
||||
key,
|
||||
'<div class="sw-empty">NASA SDO image is temporarily unavailable</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDrapFreq(key) {
|
||||
_drapFreq = key;
|
||||
_updateDrapTabs();
|
||||
const frame = document.getElementById('swDrapImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
const img = new Image();
|
||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
|
||||
img.alt = key;
|
||||
}
|
||||
}
|
||||
function selectDrapFreq(key) {
|
||||
_drapFreq = key;
|
||||
_updateDrapTabs();
|
||||
const frame = document.getElementById('swDrapImageFrame');
|
||||
if (frame) {
|
||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||
_loadImageWithFallback(
|
||||
frame,
|
||||
['/space-weather/image/' + key + '?' + _cacheBust()],
|
||||
key,
|
||||
'<div class="sw-empty">Failed to load image</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const cb = document.getElementById('swAutoRefresh');
|
||||
@@ -94,9 +101,41 @@ const SpaceWeather = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
function _stopAutoRefresh() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
}
|
||||
function _stopAutoRefresh() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
}
|
||||
|
||||
function _directImageUrlForKey(key) {
|
||||
const base = SOLAR_IMAGE_FALLBACKS[key];
|
||||
if (!base) return null;
|
||||
return base + '?' + _cacheBust();
|
||||
}
|
||||
|
||||
function _loadImageWithFallback(frame, urls, alt, failureHtml) {
|
||||
const candidates = (urls || []).filter(Boolean);
|
||||
if (!frame || candidates.length === 0) {
|
||||
if (frame) frame.innerHTML = failureHtml;
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
const img = new Image();
|
||||
img.alt = alt;
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.onload = function () {
|
||||
frame.innerHTML = '';
|
||||
frame.appendChild(img);
|
||||
};
|
||||
img.onerror = function () {
|
||||
index += 1;
|
||||
if (index < candidates.length) {
|
||||
img.src = candidates[index];
|
||||
return;
|
||||
}
|
||||
frame.innerHTML = failureHtml;
|
||||
};
|
||||
img.src = candidates[index];
|
||||
}
|
||||
|
||||
function _fetchData() {
|
||||
fetch('/space-weather/data')
|
||||
|
||||
+69
-56
@@ -13,11 +13,14 @@ const SSTV = (function() {
|
||||
let issMap = null;
|
||||
let issMarker = null;
|
||||
let issTrackLine = null;
|
||||
let issTrackPast = null;
|
||||
let issPosition = null;
|
||||
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;
|
||||
@@ -92,9 +95,11 @@ const SSTV = (function() {
|
||||
if (latInput && storedLat) latInput.value = storedLat;
|
||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||
|
||||
// Add change handlers to save and refresh
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (!locationListenersAttached) {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,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', () => {
|
||||
@@ -269,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,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()) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* Weather Satellite Mode
|
||||
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
|
||||
* Meteor LRPT decoder interface with auto-scheduler,
|
||||
* polar plot, styled real-world map, countdown, and timeline.
|
||||
*/
|
||||
|
||||
const WeatherSat = (function() {
|
||||
const METEOR_NORAD_IDS = {
|
||||
'METEOR-M2-3': 57166,
|
||||
'METEOR-M2-4': 59051,
|
||||
};
|
||||
|
||||
// State
|
||||
let isRunning = false;
|
||||
let eventSource = null;
|
||||
@@ -27,11 +32,88 @@ const WeatherSat = (function() {
|
||||
let consoleAutoHideTimer = null;
|
||||
let currentModalFilename = null;
|
||||
let locationListenersAttached = false;
|
||||
let initialized = false;
|
||||
let imageRefreshInterval = null;
|
||||
let lastDecodeJobSignature = null;
|
||||
let lastDecodeSatellite = null;
|
||||
let consoleFilter = 'all';
|
||||
|
||||
// Timezone — delegates to global InterceptTime utility
|
||||
function formatShortTime(isoString) {
|
||||
return typeof InterceptTime !== 'undefined' ? InterceptTime.shortTime(isoString) : (isoString || '--');
|
||||
}
|
||||
|
||||
function formatDateTime(isoString) {
|
||||
return typeof InterceptTime !== 'undefined' ? InterceptTime.dateTime(isoString) : (isoString || '--');
|
||||
}
|
||||
|
||||
function getTZLabel() {
|
||||
return typeof InterceptTime !== 'undefined' ? InterceptTime.tzSuffix() : '';
|
||||
}
|
||||
|
||||
function setTimezone(tz) {
|
||||
if (typeof InterceptTime !== 'undefined') InterceptTime.setTimezone(tz);
|
||||
const sel = document.getElementById('wxsatTimezone');
|
||||
if (sel && sel.value !== tz) sel.value = tz;
|
||||
applyPassFilter();
|
||||
renderGallery();
|
||||
updateTimelineLabels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an azimuth angle (0-360) to a cardinal direction label.
|
||||
*/
|
||||
function azToDir(az) {
|
||||
if (typeof az !== 'number' || isNaN(az)) return '?';
|
||||
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||
return dirs[Math.round(az / 22.5) % 16];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best upcoming pass (highest max elevation).
|
||||
*/
|
||||
function findBestPass(passList) {
|
||||
const now = new Date();
|
||||
const upcoming = passList.filter(p => {
|
||||
const end = parsePassDate(p.endTimeISO);
|
||||
return end && end > now;
|
||||
});
|
||||
if (upcoming.length === 0) return null;
|
||||
return upcoming.reduce((best, p) => (p.maxEl > best.maxEl) ? p : best, upcoming[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Weather Satellite mode
|
||||
*/
|
||||
function init() {
|
||||
// Sync timezone selector with global setting
|
||||
const tzSel = document.getElementById('wxsatTimezone');
|
||||
if (tzSel && typeof InterceptTime !== 'undefined') tzSel.value = InterceptTime.getTimezone();
|
||||
|
||||
if (initialized) {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
loadLocationInputs();
|
||||
loadPasses();
|
||||
startCountdownTimer();
|
||||
checkSchedulerStatus();
|
||||
initGroundMap();
|
||||
loadLatestDecodeJob();
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
// Listen for global timezone/format changes
|
||||
if (typeof InterceptTime !== 'undefined') {
|
||||
InterceptTime.onChange(() => {
|
||||
const sel = document.getElementById('wxsatTimezone');
|
||||
if (sel) sel.value = InterceptTime.getTimezone();
|
||||
applyPassFilter();
|
||||
renderGallery();
|
||||
updateTimelineLabels();
|
||||
});
|
||||
}
|
||||
|
||||
checkStatus();
|
||||
loadImages();
|
||||
loadLocationInputs();
|
||||
@@ -39,14 +121,8 @@ const WeatherSat = (function() {
|
||||
startCountdownTimer();
|
||||
checkSchedulerStatus();
|
||||
initGroundMap();
|
||||
|
||||
// Re-filter passes when satellite selection changes
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) {
|
||||
satSelect.addEventListener('change', () => {
|
||||
applyPassFilter();
|
||||
});
|
||||
}
|
||||
ensureImageRefresh();
|
||||
loadLatestDecodeJob();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +208,14 @@ const WeatherSat = (function() {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) satSelect.addEventListener('change', applyPassFilter);
|
||||
if (satSelect) {
|
||||
satSelect.addEventListener('change', () => {
|
||||
resetDecodeJobDisplay();
|
||||
applyPassFilter();
|
||||
loadImages();
|
||||
loadLatestDecodeJob();
|
||||
});
|
||||
}
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
@@ -302,6 +385,19 @@ const WeatherSat = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-select a satellite without starting capture.
|
||||
* Used by the satellite dashboard handoff so the user can review
|
||||
* settings before hitting Start.
|
||||
*/
|
||||
function preSelect(satellite) {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) {
|
||||
satSelect.value = satellite;
|
||||
satSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capture for a specific pass
|
||||
*/
|
||||
@@ -309,6 +405,7 @@ const WeatherSat = (function() {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) {
|
||||
satSelect.value = satellite;
|
||||
satSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
start();
|
||||
}
|
||||
@@ -521,6 +618,7 @@ const WeatherSat = (function() {
|
||||
updatePhaseIndicator('error');
|
||||
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
||||
loadImages();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,12 +703,15 @@ const WeatherSat = (function() {
|
||||
renderPasses([]);
|
||||
renderTimeline([]);
|
||||
updateCountdownFromPasses();
|
||||
updatePassAnalysis([]);
|
||||
updateGroundTrack(null);
|
||||
const passCountEl = document.getElementById('wxsatStripPassCount');
|
||||
if (passCountEl) passCountEl.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
|
||||
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=48&min_elevation=5&trajectory=true&ground_track=true`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -637,7 +738,12 @@ const WeatherSat = (function() {
|
||||
selectedPassIndex = -1;
|
||||
renderPasses(passes);
|
||||
renderTimeline(passes);
|
||||
updateTimelineLabels();
|
||||
updateCountdownFromPasses();
|
||||
updatePassAnalysis(passes);
|
||||
// Update strip pass count
|
||||
const passCountEl = document.getElementById('wxsatStripPassCount');
|
||||
if (passCountEl) passCountEl.textContent = passes.length;
|
||||
if (passes.length > 0) {
|
||||
selectPass(0);
|
||||
} else {
|
||||
@@ -669,6 +775,42 @@ const WeatherSat = (function() {
|
||||
// Update polar panel subtitle
|
||||
const polarSat = document.getElementById('wxsatPolarSat');
|
||||
if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
|
||||
|
||||
// Update pass geometry detail panel
|
||||
updatePassGeometry(pass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the AOS/TCA/LOS pass geometry detail panel.
|
||||
*/
|
||||
function updatePassGeometry(pass) {
|
||||
const panel = document.getElementById('wxsatPassGeometry');
|
||||
if (!panel) return;
|
||||
|
||||
if (!pass) {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
panel.style.display = 'flex';
|
||||
|
||||
const aosTime = document.getElementById('wxsatGeomAosTime');
|
||||
const aosAz = document.getElementById('wxsatGeomAosAz');
|
||||
const tcaEl = document.getElementById('wxsatGeomTcaEl');
|
||||
const tcaAz = document.getElementById('wxsatGeomTcaAz');
|
||||
const losTime = document.getElementById('wxsatGeomLosTime');
|
||||
const losAz = document.getElementById('wxsatGeomLosAz');
|
||||
const meta = document.getElementById('wxsatGeomMeta');
|
||||
|
||||
const tzLabel = getTZLabel();
|
||||
if (aosTime) aosTime.textContent = formatShortTime(pass.startTimeISO) + tzLabel;
|
||||
if (aosAz) aosAz.textContent = `${Math.round(pass.riseAz || 0)}\u00b0 ${azToDir(pass.riseAz)}`;
|
||||
if (tcaEl) tcaEl.textContent = `${pass.maxEl}\u00b0 el`;
|
||||
if (tcaAz) tcaAz.textContent = `${Math.round(pass.maxElAz || pass.tcaAz || 0)}\u00b0 ${azToDir(pass.maxElAz || pass.tcaAz)}`;
|
||||
if (losTime) losTime.textContent = formatShortTime(pass.endTimeISO) + tzLabel;
|
||||
if (losAz) losAz.textContent = `${Math.round(pass.setAz || 0)}\u00b0 ${azToDir(pass.setAz)}`;
|
||||
|
||||
const durMin = Math.round((pass.duration || 0) / 60);
|
||||
if (meta) meta.textContent = `${durMin} min / ${pass.quality}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -683,23 +825,42 @@ const WeatherSat = (function() {
|
||||
if (!container) return;
|
||||
|
||||
if (passList.length === 0) {
|
||||
const hasLocation = localStorage.getItem('observerLat') !== null;
|
||||
const hasLocation = localStorage.getItem('observerLat') !== null ||
|
||||
(window.ObserverLocation && ObserverLocation.isSharedEnabled() && ObserverLocation.getShared()?.lat);
|
||||
container.innerHTML = `
|
||||
<div class="wxsat-gallery-empty">
|
||||
<p>${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}</p>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; margin-bottom: 8px; opacity: 0.3;">
|
||||
${hasLocation
|
||||
? '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'
|
||||
: '<circle cx="12" cy="12" r="10"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>'}
|
||||
</svg>
|
||||
<p style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">
|
||||
${hasLocation ? 'No passes in next 24 hours' : 'Set your location'}
|
||||
</p>
|
||||
<p style="font-size: 11px; margin-top: 4px;">
|
||||
${hasLocation
|
||||
? 'All Meteor passes may be below the minimum elevation. Try again later.'
|
||||
: 'Enter lat/lon in the strip bar above or click GPS to load pass predictions'}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
// Hide geometry panel when no passes
|
||||
const geom = document.getElementById('wxsatPassGeometry');
|
||||
if (geom) geom.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const bestPass = findBestPass(passList);
|
||||
|
||||
container.innerHTML = passList.map((pass, idx) => {
|
||||
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
||||
const timeStr = pass.startTime || '--';
|
||||
const timeStr = formatDateTime(pass.startTimeISO) + getTZLabel();
|
||||
const now = new Date();
|
||||
const passStart = parsePassDate(pass.startTimeISO);
|
||||
const diffMs = passStart ? passStart - now : NaN;
|
||||
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
|
||||
const isSelected = idx === selectedPassIndex;
|
||||
const isBest = bestPass && pass.startTimeISO === bestPass.startTimeISO && pass.satellite === bestPass.satellite;
|
||||
|
||||
let countdown = '--';
|
||||
if (!Number.isFinite(diffMs)) {
|
||||
@@ -714,21 +875,29 @@ const WeatherSat = (function() {
|
||||
countdown = `in ${hrs}h${mins}m`;
|
||||
}
|
||||
|
||||
const riseDir = azToDir(pass.riseAz);
|
||||
const setDir = azToDir(pass.setAz);
|
||||
const bestBadge = isBest ? '<span class="wxsat-pass-best-badge">BEST</span>' : '';
|
||||
const durMin = Math.round((pass.duration || 0) / 60);
|
||||
const aosStr = formatShortTime(pass.startTimeISO);
|
||||
const losStr = formatShortTime(pass.endTimeISO);
|
||||
const tzLabel = getTZLabel();
|
||||
|
||||
return `
|
||||
<div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})">
|
||||
<div class="wxsat-pass-sat">
|
||||
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
|
||||
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}${bestBadge}</span>
|
||||
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
|
||||
</div>
|
||||
<div class="wxsat-pass-details">
|
||||
<span class="wxsat-pass-detail-label">Time</span>
|
||||
<span class="wxsat-pass-detail-value">${escapeHtml(timeStr)}</span>
|
||||
<span class="wxsat-pass-detail-label">Max El</span>
|
||||
<span class="wxsat-pass-detail-value">${pass.maxEl}°</span>
|
||||
<span class="wxsat-pass-detail-label">Duration</span>
|
||||
<span class="wxsat-pass-detail-value">${pass.duration} min</span>
|
||||
<span class="wxsat-pass-detail-label">Freq</span>
|
||||
<span class="wxsat-pass-detail-value">${pass.frequency} MHz</span>
|
||||
<span class="wxsat-pass-detail-label">AOS</span>
|
||||
<span class="wxsat-pass-detail-value">${escapeHtml(aosStr)}${escapeHtml(tzLabel)} · ${Math.round(pass.riseAz || 0)}° ${riseDir}</span>
|
||||
<span class="wxsat-pass-detail-label">LOS</span>
|
||||
<span class="wxsat-pass-detail-value">${escapeHtml(losStr)}${escapeHtml(tzLabel)} · ${Math.round(pass.setAz || 0)}° ${setDir}</span>
|
||||
<span class="wxsat-pass-detail-label">Peak</span>
|
||||
<span class="wxsat-pass-detail-value">${pass.maxEl}° el · ${durMin} min</span>
|
||||
<span class="wxsat-pass-detail-label">Track</span>
|
||||
<span class="wxsat-pass-detail-value">${riseDir} <span class="wxsat-dir-arrow">→</span> ${setDir}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
|
||||
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
|
||||
@@ -1296,12 +1465,18 @@ const WeatherSat = (function() {
|
||||
if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
|
||||
if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
|
||||
if (secsEl) secsEl.textContent = s.toString().padStart(2, '0');
|
||||
const passTimeStr = formatShortTime(nextPass.startTimeISO) + getTZLabel();
|
||||
if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
|
||||
if (detailEl) {
|
||||
if (isActive) {
|
||||
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
|
||||
} else {
|
||||
detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`;
|
||||
const bestPass = findBestPass(filtered);
|
||||
const durMin = Math.round((nextPass.duration || 0) / 60);
|
||||
const bestNote = bestPass && bestPass.startTimeISO !== nextPass.startTimeISO
|
||||
? ` | Best: ${bestPass.name} ${formatShortTime(bestPass.startTimeISO)}${getTZLabel()} (${bestPass.maxEl}\u00b0)`
|
||||
: '';
|
||||
detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${durMin} min${bestNote}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1353,7 +1528,7 @@ const WeatherSat = (function() {
|
||||
marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
|
||||
marker.style.left = startPct + '%';
|
||||
marker.style.width = widthPct + '%';
|
||||
marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`;
|
||||
marker.title = `${pass.name} ${formatShortTime(pass.startTimeISO)}${getTZLabel()} (${pass.maxEl}\u00b0)`;
|
||||
marker.onclick = () => selectPass(idx);
|
||||
track.appendChild(marker);
|
||||
});
|
||||
@@ -1376,6 +1551,73 @@ const WeatherSat = (function() {
|
||||
cursor.style.left = pct + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update timeline hour labels to match the selected timezone.
|
||||
*/
|
||||
function updateTimelineLabels() {
|
||||
const labels = document.querySelector('.wxsat-timeline-labels');
|
||||
if (!labels) return;
|
||||
const hours = [0, 6, 12, 18, 24];
|
||||
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 (h === 24) {
|
||||
spans[i].textContent = '24:00';
|
||||
return;
|
||||
}
|
||||
if (tz === 'UTC' || tz === 'local') {
|
||||
spans[i].textContent = `${String(h).padStart(2, '0')}:00`;
|
||||
} else {
|
||||
const d = new Date();
|
||||
d.setHours(h, 0, 0, 0);
|
||||
const opts = { hour: '2-digit', minute: '2-digit', hour12: false };
|
||||
if (ianaName) opts.timeZone = ianaName;
|
||||
spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the pass analysis bar with stats about current passes.
|
||||
*/
|
||||
function updatePassAnalysis(passList) {
|
||||
const totalEl = document.getElementById('wxsatAnalysisTotal');
|
||||
const excellentEl = document.getElementById('wxsatAnalysisExcellent');
|
||||
const goodEl = document.getElementById('wxsatAnalysisGood');
|
||||
const fairEl = document.getElementById('wxsatAnalysisFair');
|
||||
const bestEl = document.getElementById('wxsatAnalysisBest');
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = passList.filter(p => {
|
||||
const end = parsePassDate(p.endTimeISO);
|
||||
return end && end > now;
|
||||
});
|
||||
|
||||
const excellent = upcoming.filter(p => p.quality === 'excellent').length;
|
||||
const good = upcoming.filter(p => p.quality === 'good').length;
|
||||
const fair = upcoming.filter(p => p.quality === 'fair').length;
|
||||
|
||||
if (totalEl) totalEl.textContent = upcoming.length;
|
||||
if (excellentEl) excellentEl.textContent = excellent;
|
||||
if (goodEl) goodEl.textContent = good;
|
||||
if (fairEl) fairEl.textContent = fair;
|
||||
|
||||
const best = findBestPass(passList);
|
||||
if (bestEl) {
|
||||
if (best) {
|
||||
const t = formatShortTime(best.startTimeISO) + getTZLabel();
|
||||
const bestDurMin = Math.round((best.duration || 0) / 60);
|
||||
bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${bestDurMin} min)`;
|
||||
} else {
|
||||
bestEl.textContent = 'No upcoming passes';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Auto-Scheduler
|
||||
// ========================
|
||||
@@ -1534,7 +1776,12 @@ const WeatherSat = (function() {
|
||||
*/
|
||||
async function loadImages() {
|
||||
try {
|
||||
const response = await fetch('/weather-sat/images');
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
const selectedSatellite = satSelect?.value || '';
|
||||
const url = selectedSatellite
|
||||
? `/weather-sat/images?satellite=${encodeURIComponent(selectedSatellite)}`
|
||||
: '/weather-sat/images';
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
@@ -1584,12 +1831,15 @@ const WeatherSat = (function() {
|
||||
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
|
||||
});
|
||||
|
||||
// Group by date
|
||||
// Group by date (timezone-aware via global InterceptTime)
|
||||
const groups = {};
|
||||
sorted.forEach(img => {
|
||||
const dateKey = img.timestamp
|
||||
? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: 'Unknown Date';
|
||||
let dateKey = 'Unknown Date';
|
||||
if (img.timestamp) {
|
||||
dateKey = typeof InterceptTime !== 'undefined'
|
||||
? InterceptTime.dateOnly(img.timestamp)
|
||||
: new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
if (!groups[dateKey]) groups[dateKey] = [];
|
||||
groups[dateKey].push(img);
|
||||
});
|
||||
@@ -1599,6 +1849,14 @@ const WeatherSat = (function() {
|
||||
html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`;
|
||||
html += imgs.map(img => {
|
||||
const fn = escapeHtml(img.filename || img.url.split('/').pop());
|
||||
const deleteButton = img.deletable === false ? '' : `
|
||||
<div class="wxsat-image-actions">
|
||||
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="wxsat-image-card">
|
||||
<div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')">
|
||||
@@ -1609,13 +1867,7 @@ const WeatherSat = (function() {
|
||||
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wxsat-image-actions">
|
||||
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
${deleteButton}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -1707,9 +1959,14 @@ const WeatherSat = (function() {
|
||||
*/
|
||||
async function deleteAllImages() {
|
||||
if (images.length === 0) return;
|
||||
const deletableCount = images.filter(img => img.deletable !== false).length;
|
||||
if (deletableCount === 0) {
|
||||
showNotification('Weather Sat', 'Only shared ground-station imagery is available here');
|
||||
return;
|
||||
}
|
||||
const confirmed = await AppFeedback.confirmAction({
|
||||
title: 'Delete All Images',
|
||||
message: `Delete all ${images.length} decoded images? This cannot be undone.`,
|
||||
message: `Delete all ${deletableCount} local decoded images? Shared ground-station outputs will be kept.`,
|
||||
confirmLabel: 'Delete All',
|
||||
confirmClass: 'btn-danger'
|
||||
});
|
||||
@@ -1720,8 +1977,8 @@ const WeatherSat = (function() {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
images = [];
|
||||
updateImageCount(0);
|
||||
images = images.filter(img => img.deletable === false);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
showNotification('Weather Sat', `Deleted ${data.deleted} images`);
|
||||
} else {
|
||||
@@ -1738,11 +1995,146 @@ const WeatherSat = (function() {
|
||||
*/
|
||||
function formatTimestamp(isoString) {
|
||||
if (!isoString) return '--';
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
return formatDateTime(isoString) + getTZLabel();
|
||||
}
|
||||
|
||||
function ensureImageRefresh() {
|
||||
if (imageRefreshInterval) return;
|
||||
imageRefreshInterval = setInterval(() => {
|
||||
const mode = document.getElementById('weatherSatMode');
|
||||
if (!mode || !mode.classList.contains('active')) return;
|
||||
loadImages();
|
||||
loadLatestDecodeJob();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function getSelectedMeteorNorad() {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
const satellite = satSelect?.value || '';
|
||||
return METEOR_NORAD_IDS[satellite] || null;
|
||||
}
|
||||
|
||||
async function loadLatestDecodeJob() {
|
||||
const norad = getSelectedMeteorNorad();
|
||||
if (!norad) return;
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
const satellite = satSelect?.value || null;
|
||||
|
||||
if (satellite !== lastDecodeSatellite) {
|
||||
lastDecodeSatellite = satellite;
|
||||
lastDecodeJobSignature = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/ground_station/decode-jobs?norad_id=${encodeURIComponent(norad)}&backend=meteor_lrpt&limit=1`);
|
||||
const jobs = await response.json();
|
||||
if (!Array.isArray(jobs) || !jobs.length) {
|
||||
resetDecodeJobDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs[0];
|
||||
const details = job.details || {};
|
||||
const signature = `${job.id}:${job.status}:${job.error_message || ''}`;
|
||||
const captureStatus = document.getElementById('wxsatCaptureStatus');
|
||||
const captureMsg = document.getElementById('wxsatCaptureMsg');
|
||||
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
|
||||
const summary = formatDecodeJobSummary(job, details);
|
||||
|
||||
if (!isRunning) {
|
||||
if (job.status === 'queued') {
|
||||
updateStatusUI('idle', 'Decode queued');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureElapsed) captureElapsed.textContent = '--';
|
||||
if (captureStatus) captureStatus.classList.add('active');
|
||||
} else if (job.status === 'decoding') {
|
||||
updateStatusUI('decoding', 'Ground-station decode running');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureStatus) captureStatus.classList.add('active');
|
||||
} else if (job.status === 'failed') {
|
||||
updateStatusUI('idle', 'Last decode failed');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureElapsed) captureElapsed.textContent = formatDecodeJobMeta(details);
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
if (signature !== lastDecodeJobSignature) {
|
||||
showConsole(true);
|
||||
addConsoleEntry(summary, 'error');
|
||||
const context = formatDecodeJobContext(details);
|
||||
if (context) addConsoleEntry(context, 'warning');
|
||||
}
|
||||
} else if (job.status === 'complete') {
|
||||
const count = details.output_count;
|
||||
updateStatusUI('idle', count ? `Last decode: ${count} image${count === 1 ? '' : 's'}` : 'Last decode complete');
|
||||
if (captureMsg) captureMsg.textContent = summary;
|
||||
if (captureElapsed) captureElapsed.textContent = formatDecodeJobMeta(details);
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
if (signature !== lastDecodeJobSignature) {
|
||||
addConsoleEntry(
|
||||
count ? `Ground-station decode complete: ${count} image${count === 1 ? '' : 's'} produced`
|
||||
: 'Ground-station decode complete',
|
||||
'signal'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastDecodeJobSignature = signature;
|
||||
} catch (err) {
|
||||
console.error('Failed to load latest decode job:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function resetDecodeJobDisplay() {
|
||||
if (isRunning) return;
|
||||
const captureStatus = document.getElementById('wxsatCaptureStatus');
|
||||
const captureMsg = document.getElementById('wxsatCaptureMsg');
|
||||
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
if (captureMsg) captureMsg.textContent = '--';
|
||||
if (captureElapsed) captureElapsed.textContent = '--';
|
||||
updateStatusUI('idle', 'Idle');
|
||||
}
|
||||
|
||||
function formatDecodeJobSummary(job, details) {
|
||||
if (job.status === 'queued') return 'Ground-station decode queued';
|
||||
if (job.status === 'decoding') return details.message || 'Ground-station decode in progress';
|
||||
if (job.status === 'complete') {
|
||||
const count = details.output_count;
|
||||
return count ? `Ground-station decode complete: ${count} image${count === 1 ? '' : 's'} produced`
|
||||
: 'Ground-station decode complete';
|
||||
}
|
||||
if (job.status === 'failed') {
|
||||
const reasonLabels = {
|
||||
sample_rate_too_low: 'Sample rate too low for Meteor LRPT',
|
||||
invalid_sample_rate: 'Sample rate rejected by decoder',
|
||||
recording_too_small: 'Recording too small for useful decode',
|
||||
satdump_failed: 'SatDump decode failed',
|
||||
permission_error: 'Decoder could not access recording/output path',
|
||||
input_missing: 'Input recording was not accessible',
|
||||
missing_recording: 'Recording was missing when decode started',
|
||||
no_imagery_produced: 'Decode produced no imagery',
|
||||
};
|
||||
return job.error_message || reasonLabels[details.reason] || details.message || 'Last decode failed';
|
||||
}
|
||||
return details.message || 'Decode status unavailable';
|
||||
}
|
||||
|
||||
function formatDecodeJobMeta(details) {
|
||||
const parts = [];
|
||||
if (details.sample_rate) parts.push(`${Number(details.sample_rate).toLocaleString()} Hz`);
|
||||
if (details.file_size_human) parts.push(details.file_size_human);
|
||||
return parts.join(' / ') || '--';
|
||||
}
|
||||
|
||||
function formatDecodeJobContext(details) {
|
||||
const parts = [];
|
||||
if (details.reason) parts.push(`Reason: ${String(details.reason).replace(/_/g, ' ')}`);
|
||||
if (details.sample_rate) parts.push(`Sample rate ${Number(details.sample_rate).toLocaleString()} Hz`);
|
||||
if (details.file_size_human) parts.push(`Recording ${details.file_size_human}`);
|
||||
if (details.last_returncode !== undefined && details.last_returncode !== null) {
|
||||
parts.push(`Exit code ${details.last_returncode}`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1780,11 +2172,23 @@ const WeatherSat = (function() {
|
||||
const log = document.getElementById('wxsatConsoleLog');
|
||||
if (!log) return;
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`;
|
||||
entry.textContent = message;
|
||||
log.appendChild(entry);
|
||||
const type = logType || 'info';
|
||||
const now = new Date();
|
||||
const ts = typeof InterceptTime !== 'undefined'
|
||||
? InterceptTime.fullTime(now)
|
||||
: now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `wxsat-console-entry wxsat-log-${type}`;
|
||||
entry.dataset.logType = type;
|
||||
entry.innerHTML = `<span class="wxsat-console-ts">${ts}</span>${escapeHtml(message)}`;
|
||||
|
||||
// Apply current filter visibility
|
||||
if (consoleFilter !== 'all' && type !== consoleFilter) {
|
||||
entry.style.display = 'none';
|
||||
}
|
||||
|
||||
log.appendChild(entry);
|
||||
consoleEntries.push(entry);
|
||||
|
||||
// Cap at 200 entries
|
||||
@@ -1797,6 +2201,40 @@ const WeatherSat = (function() {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter console entries by log type.
|
||||
*/
|
||||
function filterConsole(filter) {
|
||||
consoleFilter = filter;
|
||||
// Update filter button states
|
||||
document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === filter);
|
||||
});
|
||||
// Show/hide entries
|
||||
consoleEntries.forEach(entry => {
|
||||
if (filter === 'all') {
|
||||
entry.style.display = '';
|
||||
} else {
|
||||
entry.style.display = entry.dataset.logType === filter ? '' : 'none';
|
||||
}
|
||||
});
|
||||
// Scroll to bottom
|
||||
const log = document.getElementById('wxsatConsoleLog');
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export console contents to clipboard.
|
||||
*/
|
||||
function exportConsole() {
|
||||
const text = consoleEntries.map(e => e.textContent).join('\n');
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showNotification('Weather Sat', 'Console log copied to clipboard');
|
||||
}).catch(() => {
|
||||
showNotification('Weather Sat', 'Failed to copy console log');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the phase indicator steps
|
||||
*/
|
||||
@@ -1859,8 +2297,14 @@ const WeatherSat = (function() {
|
||||
const log = document.getElementById('wxsatConsoleLog');
|
||||
if (log) log.innerHTML = '';
|
||||
consoleEntries = [];
|
||||
consoleFilter = 'all';
|
||||
currentPhase = 'idle';
|
||||
|
||||
// Reset filter buttons
|
||||
document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||||
});
|
||||
|
||||
document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
|
||||
step.classList.remove('active', 'completed', 'error');
|
||||
});
|
||||
@@ -1903,6 +2347,90 @@ const WeatherSat = (function() {
|
||||
stopStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load demo/sample data for UI testing without a live satellite pass.
|
||||
* Populates passes, console, and analysis bar with realistic fake data.
|
||||
*/
|
||||
function loadDemoData() {
|
||||
const now = new Date();
|
||||
|
||||
// Generate sample passes over next 24h
|
||||
const demoSats = ['METEOR-M2-3', 'METEOR-M2-4'];
|
||||
const demoPasses = [];
|
||||
|
||||
const offsets = [25, 95, 200, 340, 510, 720, 880, 1020];
|
||||
const elevations = [72, 45, 28, 63, 18, 55, 82, 35];
|
||||
const durations = [840, 720, 480, 780, 360, 660, 900, 600]; // seconds
|
||||
const riseAzs = [350, 15, 200, 310, 170, 40, 280, 90];
|
||||
const setAzs = [170, 195, 20, 130, 350, 220, 100, 270];
|
||||
|
||||
offsets.forEach((offset, i) => {
|
||||
const start = new Date(now.getTime() + offset * 60000);
|
||||
const end = new Date(start.getTime() + durations[i] * 1000);
|
||||
const sat = demoSats[i % 2];
|
||||
const el = elevations[i];
|
||||
const quality = el >= 60 ? 'excellent' : el >= 30 ? 'good' : 'fair';
|
||||
|
||||
demoPasses.push({
|
||||
id: `${sat}_demo_${i}`,
|
||||
satellite: sat,
|
||||
name: sat === 'METEOR-M2-3' ? 'Meteor-M2-3' : 'Meteor-M2-4',
|
||||
frequency: 137.9,
|
||||
mode: 'LRPT',
|
||||
startTime: start.toISOString().replace('T', ' ').slice(0, 16) + ' UTC',
|
||||
startTimeISO: start.toISOString(),
|
||||
endTimeISO: end.toISOString(),
|
||||
maxEl: el,
|
||||
maxElAz: (riseAzs[i] + setAzs[i]) / 2,
|
||||
riseAz: riseAzs[i],
|
||||
setAz: setAzs[i],
|
||||
duration: durations[i],
|
||||
quality: quality,
|
||||
trajectory: [],
|
||||
groundTrack: [],
|
||||
});
|
||||
});
|
||||
|
||||
allPasses = demoPasses;
|
||||
applyPassFilter();
|
||||
|
||||
// Simulate console output
|
||||
clearConsole();
|
||||
showConsole(true);
|
||||
const demoLogs = [
|
||||
['SatDump v1.2.2 initialized', 'info'],
|
||||
['Pipeline: meteor_m2-x_lrpt', 'info'],
|
||||
['Frequency: 137.900 MHz | Sample rate: 2.4 MHz', 'info'],
|
||||
['RTL-SDR device 0 (SN: 00000101) opened', 'info'],
|
||||
['Tuning to 137900000 Hz...', 'info'],
|
||||
['Gain set to 40.0 dB', 'debug'],
|
||||
['Waiting for signal...', 'info'],
|
||||
['LRPT signal detected! SNR: 8.2 dB', 'signal'],
|
||||
['Viterbi lock acquired', 'signal'],
|
||||
['Frame sync OK - decoding frames', 'signal'],
|
||||
['Decoding LRPT... 15%', 'progress'],
|
||||
['Decoding LRPT... 30%', 'progress'],
|
||||
['Decoding LRPT... 45%', 'progress'],
|
||||
['Channel 1 (visible) - 1540 lines', 'info'],
|
||||
['Channel 2 (infrared) - 1540 lines', 'info'],
|
||||
['Decoding LRPT... 60%', 'progress'],
|
||||
['Decoding LRPT... 75%', 'progress'],
|
||||
['Decoding LRPT... 90%', 'progress'],
|
||||
['Image saved: meteor_m2-3_rgb_composite.png (2.4 MB)', 'save'],
|
||||
['Image saved: meteor_m2-3_channel_1.png (1.1 MB)', 'save'],
|
||||
['Image saved: meteor_m2-3_thermal.png (1.3 MB)', 'save'],
|
||||
['Decoding complete - 3 images produced', 'info'],
|
||||
['Signal lost - satellite below horizon', 'warning'],
|
||||
['Pass duration: 13m 42s', 'info'],
|
||||
];
|
||||
|
||||
demoLogs.forEach((entry, i) => {
|
||||
setTimeout(() => addConsoleEntry(entry[0], entry[1]), i * 120);
|
||||
});
|
||||
|
||||
showNotification('Weather Sat', 'Demo data loaded - showing sample passes and console output');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
@@ -1910,6 +2438,7 @@ const WeatherSat = (function() {
|
||||
destroy,
|
||||
start,
|
||||
stop,
|
||||
preSelect,
|
||||
startPass,
|
||||
selectPass,
|
||||
testDecode,
|
||||
@@ -1923,6 +2452,11 @@ const WeatherSat = (function() {
|
||||
toggleScheduler,
|
||||
invalidateMap,
|
||||
toggleConsole,
|
||||
setTimezone,
|
||||
filterConsole,
|
||||
exportConsole,
|
||||
clearConsole,
|
||||
loadDemoData,
|
||||
_getModalFilename: () => currentModalFilename,
|
||||
};
|
||||
})();
|
||||
|
||||
+1917
-1876
File diff suppressed because it is too large
Load Diff
+8
-117
@@ -1,122 +1,13 @@
|
||||
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
||||
const CACHE_NAME = 'intercept-v3';
|
||||
|
||||
const NETWORK_ONLY_PREFIXES = [
|
||||
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
|
||||
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
|
||||
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
|
||||
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
|
||||
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
|
||||
'/recordings/', '/controller/', '/ops/',
|
||||
];
|
||||
|
||||
const STATIC_PREFIXES = [
|
||||
'/static/css/',
|
||||
'/static/js/',
|
||||
'/static/icons/',
|
||||
'/static/fonts/',
|
||||
];
|
||||
|
||||
const CACHE_EXACT = ['/manifest.json'];
|
||||
|
||||
function isHttpRequest(req) {
|
||||
const url = new URL(req.url);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
}
|
||||
|
||||
function isNetworkOnly(req) {
|
||||
if (req.method !== 'GET') return true;
|
||||
const accept = req.headers.get('Accept') || '';
|
||||
if (accept.includes('text/event-stream')) return true;
|
||||
const url = new URL(req.url);
|
||||
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
function isStaticAsset(req) {
|
||||
const url = new URL(req.url);
|
||||
if (CACHE_EXACT.includes(url.pathname)) return true;
|
||||
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
function fallbackResponse(req, status = 503) {
|
||||
const accept = req.headers.get('Accept') || '';
|
||||
if (accept.includes('application/json')) {
|
||||
return new Response(
|
||||
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
|
||||
{
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return new Response('', {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Offline', {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
/* INTERCEPT Service Worker disabled to avoid stale cached static assets. */
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const req = e.request;
|
||||
|
||||
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
|
||||
if (!isHttpRequest(req)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always bypass service worker for non-GET and streaming routes
|
||||
if (isNetworkOnly(req)) {
|
||||
e.respondWith(
|
||||
fetch(req).catch(() => fallbackResponse(req, 503))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets
|
||||
if (isStaticAsset(req)) {
|
||||
e.respondWith(
|
||||
caches.open(CACHE_NAME).then(cache =>
|
||||
cache.match(req).then(cached => {
|
||||
if (cached) {
|
||||
// Revalidate in background
|
||||
fetch(req).then(res => {
|
||||
if (res && res.status === 200) cache.put(req, res.clone());
|
||||
}).catch(() => {});
|
||||
return cached;
|
||||
}
|
||||
return fetch(req).then(res => {
|
||||
if (res && res.status === 200) cache.put(req, res.clone());
|
||||
return res;
|
||||
}).catch(() => fallbackResponse(req, 504));
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for HTML pages
|
||||
e.respondWith(
|
||||
fetch(req).catch(() =>
|
||||
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
|
||||
)
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key.startsWith('intercept-')).map((key) => caches.delete(key))))
|
||||
.then(() => self.registration.unregister())
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
+411
-236
File diff suppressed because it is too large
Load Diff
@@ -4,27 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
||||
<!-- Preconnect hints -->
|
||||
{% if offline_settings.assets_source != 'local' %}
|
||||
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
||||
{% endif %}
|
||||
{% if offline_settings.fonts_source != 'local' %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
{% endif %}
|
||||
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
|
||||
<!-- Fonts - Conditional CDN/Local loading -->
|
||||
{% if offline_settings.fonts_source == 'local' %}
|
||||
<!-- Dedicated dashboards always use bundled assets so navigation is not
|
||||
blocked by external CDN reachability. -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<!-- Leaflet CSS -->
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endif %}
|
||||
<!-- Core CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||
@@ -32,18 +15,17 @@
|
||||
<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 }};
|
||||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||||
</script>
|
||||
{% if offline_settings.assets_source == 'local' %}
|
||||
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
{% else %}
|
||||
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
{% endif %}
|
||||
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body data-mode="ais">
|
||||
<!-- Radar background effects -->
|
||||
<div class="radar-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
@@ -185,8 +167,8 @@
|
||||
<div class="control-group">
|
||||
<span class="control-group-label">LOCATION</span>
|
||||
<div class="control-group-items">
|
||||
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
||||
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||
<input type="text" id="obsLat" value="{{ default_latitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
||||
<input type="text" id="obsLon" value="{{ default_longitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,6 +204,7 @@
|
||||
|
||||
// State
|
||||
let vesselMap = null;
|
||||
let aisMapOverlays = null;
|
||||
let vessels = {};
|
||||
let markers = {};
|
||||
let selectedMmsi = null;
|
||||
@@ -248,7 +231,9 @@
|
||||
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||||
return ObserverLocation.getForModule('ais_observerLocation');
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
const defaultLat = window.INTERCEPT_DEFAULT_LAT || 51.5074;
|
||||
const defaultLon = window.INTERCEPT_DEFAULT_LON || -0.1278;
|
||||
return { lat: defaultLat, lon: defaultLon };
|
||||
})();
|
||||
let rangeRingsLayer = null;
|
||||
let observerMarker = null;
|
||||
@@ -404,7 +389,6 @@
|
||||
15: 'Undefined'
|
||||
};
|
||||
|
||||
// Initialize map
|
||||
async function initMap() {
|
||||
// Guard against double initialization (e.g. bfcache restore)
|
||||
const container = document.getElementById('vesselMap');
|
||||
@@ -415,38 +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;
|
||||
|
||||
// Add fallback tile layer immediately so the map is never blank
|
||||
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(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], {
|
||||
@@ -774,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) {
|
||||
@@ -1612,7 +1583,21 @@
|
||||
<!-- Help Modal -->
|
||||
{% include 'partials/help-modal.html' %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
<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({ startStreams: false });
|
||||
VoiceAlerts.scheduleStreamStart(20000);
|
||||
}
|
||||
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Agent Manager -->
|
||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||
|
||||
+792
-316
File diff suppressed because it is too large
Load Diff
@@ -518,7 +518,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body data-mode="controller_monitor">
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
NETWORK MONITOR
|
||||
@@ -1117,7 +1117,20 @@
|
||||
<!-- Help Modal -->
|
||||
{% include 'partials/help-modal.html' %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
<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/core/global-nav.js') }}"></script>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof VoiceAlerts !== 'undefined') {
|
||||
VoiceAlerts.init({ startStreams: false });
|
||||
VoiceAlerts.scheduleStreamStart(20000);
|
||||
}
|
||||
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -270,6 +270,17 @@
|
||||
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Drone Intelligence Mode</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Detects UAVs via three simultaneous vectors: Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz</li>
|
||||
<li>Parses ASTM F3411 Remote ID broadcast frames — captures drone ID, operator ID, and GPS position</li>
|
||||
<li>RF fingerprinting on 433/868 MHz ISM bands and 2.4/5.8 GHz to detect drone control links and video downlinks</li>
|
||||
<li>Correlates observations across all vectors into unified <em>DroneContact</em> entries with risk scoring</li>
|
||||
<li>Risk levels: <strong>High</strong> (non-compliant / no Remote ID), <strong>Medium</strong> (multi-vector or RSSI delta >15 dB), <strong>Low</strong> (compliant, single vector)</li>
|
||||
<li>Live map shows last known position for Remote ID contacts with GPS data</li>
|
||||
<li>Requires: WiFi adapter (monitor mode) for BLE Remote ID, RTL-SDR for 433/868 MHz, HackRF for 2.4/5.8 GHz</li>
|
||||
</ul>
|
||||
|
||||
<h3>Network Monitor</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Aggregates data from multiple remote INTERCEPT agents</li>
|
||||
|
||||
@@ -188,33 +188,49 @@
|
||||
return `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:${color};">${lbl}</span>`;
|
||||
}
|
||||
|
||||
// TODO: Similar to renderAcarsCard in templates/adsb_dashboard.html — consider unifying
|
||||
function renderAcarsMainCard(data) {
|
||||
const flight = escapeHtml(data.flight || 'UNKNOWN');
|
||||
const tail = escapeHtml(data.tail || data.reg || '');
|
||||
const type = data.message_type || 'other';
|
||||
const badge = acarsMainTypeBadge(type);
|
||||
const desc = escapeHtml(data.label_description || (data.label ? 'Label: ' + data.label : ''));
|
||||
const text = data.text || data.msg || '';
|
||||
const truncText = escapeHtml(text.length > 150 ? text.substring(0, 150) + '...' : text);
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const truncText = escapeHtml(text.length > 200 ? text.substring(0, 200) + '...' : text);
|
||||
const time = typeof InterceptTime !== 'undefined'
|
||||
? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
|
||||
: new Date().toLocaleTimeString();
|
||||
|
||||
let parsedHtml = '';
|
||||
if (data.parsed) {
|
||||
const p = data.parsed;
|
||||
if (type === 'position' && p.lat !== undefined) {
|
||||
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' • ' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' → ' + escapeHtml(String(p.destination)) : ''}</div>`;
|
||||
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' • FL' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' → ' + escapeHtml(String(p.destination)) : ''}</div>`;
|
||||
} else if (type === 'engine_data') {
|
||||
const parts = [];
|
||||
Object.keys(p).forEach(k => parts.push(escapeHtml(k) + ': ' + escapeHtml(String(p[k].value))));
|
||||
Object.keys(p).forEach(k => {
|
||||
const val = typeof p[k] === 'object' ? p[k].value : p[k];
|
||||
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(val)));
|
||||
});
|
||||
if (parts.length) parsedHtml = `<div style="color:#ff9500;margin-top:2px;font-size:10px;">${parts.slice(0, 4).join(' | ')}</div>`;
|
||||
} else if (type === 'oooi' && p.origin) {
|
||||
parsedHtml = `<div style="color:var(--accent-cyan);margin-top:2px;font-size:10px;">${escapeHtml(String(p.origin))} → ${escapeHtml(String(p.destination))}${p.out ? ' | OUT ' + escapeHtml(String(p.out)) : ''}${p.off ? ' OFF ' + escapeHtml(String(p.off)) : ''}${p.on ? ' ON ' + escapeHtml(String(p.on)) : ''}${p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : ''}</div>`;
|
||||
} else if (type === 'weather' && (p.wind_speed || p.temperature)) {
|
||||
const wx = [];
|
||||
if (p.wind_speed) wx.push('Wind ' + escapeHtml(String(p.wind_speed)) + (p.wind_dir ? '/' + escapeHtml(String(p.wind_dir)) : ''));
|
||||
if (p.temperature) wx.push(escapeHtml(String(p.temperature)) + '°C');
|
||||
if (p.turbulence) wx.push('Turb: ' + escapeHtml(String(p.turbulence)));
|
||||
if (wx.length) parsedHtml = `<div style="color:#00d4ff;margin-top:2px;font-size:10px;">${wx.join(' | ')}</div>`;
|
||||
} else if (type === 'cpdlc') {
|
||||
const cpdlcText = p.message || p.text || '';
|
||||
if (cpdlcText) parsedHtml = `<div style="color:#b388ff;margin-top:2px;font-size:10px;font-weight:600;">${escapeHtml(String(cpdlcText))}</div>`;
|
||||
} else if (type === 'squawk' && p.squawk) {
|
||||
parsedHtml = `<div style="color:#ff6b6b;margin-top:2px;font-size:10px;font-weight:600;">Squawk: ${escapeHtml(String(p.squawk))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="acars-feed-card" style="padding:6px 8px;border-bottom:1px solid var(--border-color);animation:fadeInMsg 0.3s ease;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">
|
||||
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}</span>
|
||||
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}${tail ? ' <span style="color:var(--text-muted);font-weight:normal;font-size:9px;">(' + tail + ')</span>' : ''}</span>
|
||||
<span style="color:var(--text-muted);font-size:9px;">${time}</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;">${badge} <span style="color:var(--text-primary);">${desc}</span></div>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<!-- DRONE INTELLIGENCE MODE -->
|
||||
<div id="droneMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Drone Intelligence</h3>
|
||||
<p class="info-text" style="margin-bottom: 12px;">
|
||||
Multi-vector UAV detection: Remote ID (WiFi/BLE), 433/868 MHz control links, 2.4/5.8 GHz wideband.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Detection Vectors</h3>
|
||||
<div id="droneVectorStatus" class="drone-vector-pills">
|
||||
<span class="drone-vector-pill" id="dronePillRemoteId">Remote ID</span>
|
||||
<span class="drone-vector-pill" id="dronePill433">433 MHz</span>
|
||||
<span class="drone-vector-pill" id="dronePillHackrf">2.4 / 5.8 GHz</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>WiFi Interface</h3>
|
||||
<div class="form-group">
|
||||
<label>Interface (monitor mode)</label>
|
||||
<select id="droneWifiIface">
|
||||
<option value="">Loading interfaces…</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>SDR Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>RTL-SDR Device (433 MHz)</label>
|
||||
<select id="droneRtlIndex">
|
||||
<option value="">Loading devices…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="droneUseHackrf" checked>
|
||||
Use HackRF (2.4 / 5.8 GHz)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="droneDeviceWarnings" class="info-text" style="display:none; color:var(--accent-yellow); font-size:10px; padding: 0 4px;"></div>
|
||||
|
||||
<div class="section">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="droneStartBtn" class="run-btn" style="flex:1;">Start</button>
|
||||
<button id="droneStopBtn" class="stop-btn" style="flex:1;" disabled>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<p class="info-text">
|
||||
Status: <span id="droneStatusText" style="color:var(--accent-yellow);">Standby</span>
|
||||
</p>
|
||||
<p class="info-text">
|
||||
Contacts: <span id="droneContactCount">0</span>
|
||||
|
|
||||
Non-compliant: <span id="droneNonCompliantCount" style="color:var(--accent-red);">0</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,176 @@
|
||||
{# Meshcore Mode
|
||||
meshcore-strip and meshcore-body are direct children of #meshcoreVisuals,
|
||||
matching the Meshtastic pattern. #meshcoreMode is an empty element kept
|
||||
only so the switchMode JS active-class toggle has something to reference. #}
|
||||
<div id="meshcoreMode" style="display:none;"></div>
|
||||
|
||||
{# ── Connection Strip ── #}
|
||||
<div class="meshcore-strip">
|
||||
<div class="meshcore-strip-group">
|
||||
<span class="meshcore-status-dot" id="meshcoreStatusDot"></span>
|
||||
<span id="meshcoreStatusText" class="meshcore-strip-status-text">Disconnected</span>
|
||||
</div>
|
||||
<div class="meshcore-strip-divider"></div>
|
||||
<div class="meshcore-strip-group">
|
||||
<div class="meshcore-transport-tabs" id="meshcoreTransportTabs">
|
||||
<div class="meshcore-transport-tab active" data-transport="serial" onclick="MeshCore.selectTransport('serial')">Serial</div>
|
||||
<div class="meshcore-transport-tab" data-transport="tcp" onclick="MeshCore.selectTransport('tcp')">TCP</div>
|
||||
<div class="meshcore-transport-tab" data-transport="ble" onclick="MeshCore.selectTransport('ble')">BLE</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="meshcoreSerialConfig" class="meshcore-strip-group">
|
||||
<select id="meshcorePortSelect" class="meshcore-strip-select">
|
||||
<option value="">Auto-detect</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="meshcoreTcpConfig" class="meshcore-strip-group" style="display:none;">
|
||||
<input id="meshcoreTcpHost" type="text" placeholder="Host / IP" value="localhost" class="meshcore-strip-input" style="width:120px;">
|
||||
<input id="meshcoreTcpPort" type="number" placeholder="Port" value="5000" class="meshcore-strip-input" style="width:70px;">
|
||||
</div>
|
||||
<div id="meshcoreBleConfig" class="meshcore-strip-group" style="display:none;">
|
||||
<select id="meshcoreBleSelect" class="meshcore-strip-select">
|
||||
<option value="">Scan for devices...</option>
|
||||
</select>
|
||||
<button class="meshcore-strip-btn" onclick="MeshCore.scanBle()">Scan</button>
|
||||
</div>
|
||||
<div class="meshcore-strip-group">
|
||||
<button class="meshcore-strip-btn connect" id="meshcoreConnectBtn" onclick="MeshCore.connect()">Connect</button>
|
||||
<button class="meshcore-strip-btn disconnect" id="meshcoreDisconnectBtn" onclick="MeshCore.disconnect()" disabled>Disconnect</button>
|
||||
</div>
|
||||
<div class="meshcore-strip-divider"></div>
|
||||
<div class="meshcore-strip-group">
|
||||
<div class="meshcore-strip-stat">
|
||||
<span class="meshcore-strip-value" id="meshcoreNodeCount">0</span>
|
||||
<span class="meshcore-strip-label">NODES</span>
|
||||
</div>
|
||||
<div class="meshcore-strip-stat">
|
||||
<span class="meshcore-strip-value" id="meshcoreMsgCount">0</span>
|
||||
<span class="meshcore-strip-label">MSGS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meshcore-strip-divider"></div>
|
||||
<div class="meshcore-strip-group">
|
||||
<button class="meshcore-strip-btn" onclick="MeshCore.showAddContact()">+ Contact</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Main body ── #}
|
||||
<div class="meshcore-body">
|
||||
|
||||
{# Left panel: contacts + nodes #}
|
||||
<div class="meshcore-panel" id="meshcoreSidePanel">
|
||||
<div class="meshcore-panel-section">
|
||||
<div class="meshcore-panel-title">Contacts</div>
|
||||
<div id="meshcoreContactList">
|
||||
<div class="meshcore-empty">No contacts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meshcore-panel-section meshcore-panel-section--grow">
|
||||
<div class="meshcore-panel-title">Nodes</div>
|
||||
<div id="meshcoreNodeList">
|
||||
<div class="meshcore-empty">No nodes seen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Right: tabs + content #}
|
||||
<div class="meshcore-content">
|
||||
<div class="meshcore-tabs">
|
||||
<div class="meshcore-tab active" data-tab="messages" onclick="MeshCore.switchTab('messages')">Messages</div>
|
||||
<div class="meshcore-tab" data-tab="map" onclick="MeshCore.switchTab('map')">Map</div>
|
||||
<div class="meshcore-tab" data-tab="repeaters" onclick="MeshCore.switchTab('repeaters')">Repeaters</div>
|
||||
<div class="meshcore-tab" data-tab="telemetry" onclick="MeshCore.switchTab('telemetry')">Telemetry</div>
|
||||
</div>
|
||||
|
||||
{# Messages tab #}
|
||||
<div class="meshcore-tab-panel active" id="meshcoreTabMessages">
|
||||
<div class="meshcore-messages" id="meshcoreMessageFeed">
|
||||
<div class="meshcore-empty" style="padding:24px;text-align:center;">
|
||||
Connect to a Meshcore device to see messages
|
||||
</div>
|
||||
</div>
|
||||
<div class="meshcore-compose">
|
||||
<select id="meshcoreRecipientSelect" class="meshcore-compose-select">
|
||||
<option value="BROADCAST">Broadcast</option>
|
||||
</select>
|
||||
<input id="meshcoreComposeInput" type="text" placeholder="Type a message (max 237 chars)..." maxlength="237"
|
||||
class="meshcore-compose-input" onkeydown="if(event.key==='Enter')MeshCore.sendMessage()">
|
||||
<button class="meshcore-compose-btn" onclick="MeshCore.sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Map tab #}
|
||||
<div class="meshcore-tab-panel" id="meshcoreTabMap">
|
||||
<div id="meshcoreMap"></div>
|
||||
</div>
|
||||
|
||||
{# Repeaters tab #}
|
||||
<div class="meshcore-tab-panel" id="meshcoreTabRepeaters" style="overflow-y:auto;padding:12px;">
|
||||
<table class="meshcore-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Node ID</th><th>Hops</th><th>SNR</th><th>Battery</th><th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="meshcoreRepeaterTableBody">
|
||||
<tr><td colspan="6" class="meshcore-empty" style="text-align:center;padding:16px;">No repeaters detected</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Telemetry tab #}
|
||||
<div class="meshcore-tab-panel" id="meshcoreTabTelemetry" style="padding:12px;overflow-y:auto;">
|
||||
<div style="margin-bottom:8px;">
|
||||
<label class="meshcore-label">Node: </label>
|
||||
<select id="meshcoreTelemetryNodeSelect" onchange="MeshCore.loadTelemetry(this.value)" class="meshcore-strip-select">
|
||||
<option value="">Select a node</option>
|
||||
</select>
|
||||
</div>
|
||||
<canvas id="meshcoreTelemetryChart" style="max-height:300px;"></canvas>
|
||||
</div>
|
||||
|
||||
</div>{# /meshcore-content #}
|
||||
</div>{# /meshcore-body #}
|
||||
|
||||
{# ── Add Contact Modal ── #}
|
||||
<div id="meshcoreAddContactModal" class="signal-details-modal" style="display:none;">
|
||||
<div class="signal-details-modal-backdrop" onclick="MeshCore.closeAddContact()"></div>
|
||||
<div class="signal-details-modal-content" style="max-width:400px;">
|
||||
<div class="signal-details-modal-header">
|
||||
<h3>Add Contact</h3>
|
||||
<button class="signal-details-modal-close" onclick="MeshCore.closeAddContact()">×</button>
|
||||
</div>
|
||||
<div class="signal-details-modal-body" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div>
|
||||
<label class="meshcore-label" style="display:block;margin-bottom:3px;">Node ID</label>
|
||||
<input id="meshcoreContactNodeId" type="text" placeholder="e.g. NODE001" class="mock-input" style="width:100%;box-sizing:border-box;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="meshcore-label" style="display:block;margin-bottom:3px;">Name</label>
|
||||
<input id="meshcoreContactName" type="text" placeholder="Display name" class="mock-input" style="width:100%;box-sizing:border-box;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="meshcore-label" style="display:block;margin-bottom:3px;">Public Key</label>
|
||||
<input id="meshcoreContactKey" type="text" placeholder="Base64 public key" class="mock-input" style="width:100%;box-sizing:border-box;font-family:var(--font-mono);font-size:11px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-details-modal-footer" style="display:flex;gap:8px;">
|
||||
<button class="preset-btn" onclick="MeshCore.closeAddContact()" style="flex:1;">Cancel</button>
|
||||
<button class="run-btn" onclick="MeshCore.saveContact()" style="flex:1;">Add Contact</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Traceroute Modal ── #}
|
||||
<div id="meshcoreTracerouteModal" class="signal-details-modal" style="display:none;">
|
||||
<div class="signal-details-modal-backdrop" onclick="MeshCore.closeTraceroute()"></div>
|
||||
<div class="signal-details-modal-content" style="max-width:600px;">
|
||||
<div class="signal-details-modal-header">
|
||||
<h3>Traceroute Result</h3>
|
||||
<button class="signal-details-modal-close" onclick="MeshCore.closeTraceroute()">×</button>
|
||||
</div>
|
||||
<div class="signal-details-modal-body">
|
||||
<div class="meshcore-traceroute-hops" id="meshcoreTracerouteHops"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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">
|
||||
|
||||
@@ -6,26 +6,94 @@
|
||||
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
|
||||
</div>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Receive and decode weather images from NOAA and Meteor satellites.
|
||||
Uses SatDump for live SDR capture and image processing.
|
||||
Receive and decode Meteor LRPT weather imagery.
|
||||
Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Getting Started Guide -->
|
||||
<div class="section">
|
||||
<h3>Getting Started</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.6;">
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">What are Meteor satellites?</strong>
|
||||
<p style="margin-top: 6px;">
|
||||
Russia's <strong style="color: var(--text-primary);">Meteor-M2-3</strong> and <strong style="color: var(--text-primary);">Meteor-M2-4</strong>
|
||||
are polar-orbiting weather satellites that continuously transmit real-time color imagery (clouds, land, sea) at <strong style="color: var(--text-primary);">137.900 MHz</strong>
|
||||
using the LRPT digital format. Unlike old analog NOAA APT, LRPT produces sharp, full-color images.
|
||||
</p>
|
||||
<p style="margin-top: 6px;">
|
||||
They orbit ~830 km high, circling the Earth every ~100 minutes in a near-polar sun-synchronous orbit.
|
||||
From any location, you'll typically get <strong style="color: var(--text-primary);">4–8 usable passes per day</strong>,
|
||||
each lasting 8–15 minutes as the satellite crosses your sky.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Step-by-step</strong>
|
||||
<ol style="margin: 6px 0 0 16px; padding: 0;">
|
||||
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Set your location</strong> — Enter your lat/lon in the strip bar above (or click GPS). This is required for pass predictions.</li>
|
||||
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Check upcoming passes</strong> — The pass list shows when each satellite will be overhead. Higher max elevation = better signal. Passes above 30° are "good", above 60° are "excellent".</li>
|
||||
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Prepare your antenna</strong> — You need a 137 MHz antenna outdoors with clear sky (see Antenna Guide below). A $5 V-dipole works for high passes.</li>
|
||||
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Click Capture</strong> on a pass card when it's about to start, or enable <strong style="color: var(--text-primary);">AUTO</strong> to let the scheduler capture automatically.</li>
|
||||
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Wait for images</strong> — SatDump will tune, lock the signal, and decode. Decoded images appear in the gallery after the pass completes.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">When to look</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Best passes:</strong> When the satellite is high overhead (>30° elevation). The countdown timer shows the next one.</li>
|
||||
<li><strong style="color: var(--text-primary);">Day vs night:</strong> Daytime passes produce visible-light imagery. Night passes still work but only produce infrared/thermal images.</li>
|
||||
<li><strong style="color: var(--text-primary);">Both satellites share 137.9 MHz</strong> so they won't transmit at the same time. You'll see separate pass predictions for each.</li>
|
||||
<li><strong style="color: var(--text-primary);">Pass direction:</strong> Meteor satellites travel roughly north→south or south→north. The pass cards show the exact rise/set direction.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">What you need</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">SDR receiver</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RTL-SDR V3/V4 ($25-35)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Antenna</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz V-dipole ($5 DIY) or QFH ($20-30)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">LNA (optional)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz filtered, at antenna ($15-25)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Location</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Outdoors, clear sky view</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">No hardware?</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Use <em>Load Demo Data</em> below to explore the UI</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Satellite</h3>
|
||||
<div class="form-group">
|
||||
<label>Select Satellite</label>
|
||||
<select id="weatherSatSelect" class="mode-select">
|
||||
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
|
||||
<option value="" selected>All Meteor Satellites</option>
|
||||
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option>
|
||||
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
|
||||
<option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option>
|
||||
<option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option>
|
||||
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
|
||||
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud (fallback)</option>
|
||||
</select>
|
||||
</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;">
|
||||
@@ -35,10 +103,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide - detailed -->
|
||||
<!-- Antenna Guide - detailed (collapsed by default) -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<h3 onclick="this.parentElement.querySelector('.wxsat-antenna-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
||||
Antenna Guide
|
||||
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</span>
|
||||
</h3>
|
||||
<div class="wxsat-antenna-body wxsat-test-decode-body collapsed" style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
|
||||
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
|
||||
137 MHz band — your stock SDR antenna will NOT work.
|
||||
@@ -72,7 +143,7 @@
|
||||
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
|
||||
</ul>
|
||||
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||
Best starter antenna. Good enough for clear NOAA images with a direct overhead pass.
|
||||
Best starter antenna. Good enough for a clean Meteor LRPT pass when the satellite gets high overhead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +207,7 @@
|
||||
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
|
||||
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (<10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
|
||||
Recommended: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)</li>
|
||||
Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li>
|
||||
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -165,10 +236,6 @@
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">NOAA (APT) bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~40 kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
|
||||
@@ -180,40 +247,44 @@
|
||||
|
||||
<div class="section">
|
||||
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
||||
Test Decode (File)
|
||||
Offline Decode (IQ File)
|
||||
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</span>
|
||||
</h3>
|
||||
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||
Decode a pre-recorded IQ or WAV file without SDR hardware.
|
||||
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files.
|
||||
Decode a pre-recorded Meteor IQ baseband file without SDR hardware.
|
||||
You need an actual <code>.raw</code>, <code>.sigmf-data</code>, or <code>.wav</code> recording of a Meteor pass.
|
||||
</p>
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px; color: var(--text-dim); line-height: 1.5;">
|
||||
<strong style="color: var(--text-secondary);">Where to get a test file:</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0;">
|
||||
<li>Record one yourself with <code>rtl_sdr -f 137900000 -s 2400000 meteor.raw</code> during a pass</li>
|
||||
<li>Download samples from <a href="https://www.sigidwiki.com/wiki/Meteor-M_LRPT" target="_blank" rel="noopener" style="color: var(--accent-cyan);">SigID Wiki</a> or <a href="https://www.sondehub.org/" target="_blank" rel="noopener" style="color: var(--accent-cyan);">community forums</a></li>
|
||||
<li>Place the file in <code>data/weather_sat/</code> on the server</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Satellite</label>
|
||||
<select id="wxsatTestSatSelect" class="mode-select">
|
||||
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
|
||||
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
|
||||
<option value="NOAA-15">NOAA-15 (APT)</option>
|
||||
<option value="NOAA-18">NOAA-18 (APT)</option>
|
||||
<option value="NOAA-19">NOAA-19 (APT)</option>
|
||||
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>File Path (server-side)</label>
|
||||
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
|
||||
<label>File Path (server-side, relative to app root)</label>
|
||||
<input type="text" id="wxsatTestFilePath" placeholder="data/weather_sat/my_recording.raw" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sample Rate</label>
|
||||
<select id="wxsatTestSampleRate" class="mode-select">
|
||||
<option value="11025">11025 Hz (WAV audio APT)</option>
|
||||
<option value="48000">48000 Hz (WAV audio APT)</option>
|
||||
<option value="500000">500 kHz (IQ LRPT)</option>
|
||||
<option value="1000000" selected>1 MHz (IQ default)</option>
|
||||
<option value="2000000">2 MHz (IQ wideband)</option>
|
||||
<option value="1000000">1 MHz (IQ narrow)</option>
|
||||
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
||||
Test Decode
|
||||
Decode File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,14 +306,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Debug / Test</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||
Load sample pass data and console output to test the UI without an SDR or live satellite pass.
|
||||
</p>
|
||||
<button class="mode-btn" onclick="WeatherSat.loadDemoData()" style="width: 100%;">
|
||||
Load Demo Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
SatDump Documentation
|
||||
</a>
|
||||
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NOAA Reception Guide
|
||||
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-meteor-m2-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
Meteor Reception Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<!-- Cheat Sheet Modal -->
|
||||
<div id="cheatSheetModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)CheatSheets.hide()">
|
||||
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:480px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
|
||||
<button onclick="CheatSheets.hide()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;">✕</button>
|
||||
<div id="cheatSheetContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts Modal -->
|
||||
<div id="kbShortcutsModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)KeyboardShortcuts.hideHelp()">
|
||||
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:520px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
|
||||
<button onclick="KeyboardShortcuts.hideHelp()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;">✕</button>
|
||||
<h2 style="margin:0 0 16px; font-size:16px; color:var(--accent-cyan, #4aa3ff); font-family:var(--font-mono);">Keyboard Shortcuts</h2>
|
||||
<table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;">
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+W</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Waterfall</td></tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+M</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle voice mute</td></tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+S</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle sidebar</td></tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+C</td><td style="padding:6px 8px; color:var(--text-secondary);">Show cheat sheet for current mode</td></tr>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+1..9</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Nth mode in current group</td></tr>
|
||||
<tr><td style="padding:6px 8px; color:var(--accent-cyan);">Escape</td><td style="padding:6px 8px; color:var(--text-secondary);">Close modal</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,7 +16,12 @@
|
||||
|
||||
{% macro mode_item(mode, label, icon_svg, href=None) -%}
|
||||
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||
{%- if href %}
|
||||
{%- if mode == 'satellite' %}
|
||||
<a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||
<span class="nav-label">{{ label }}</span>
|
||||
</a>
|
||||
{%- elif href %}
|
||||
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||
<span class="nav-label">{{ label }}</span>
|
||||
@@ -36,7 +41,11 @@
|
||||
|
||||
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
|
||||
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||
{%- if href %}
|
||||
{%- if mode == 'satellite' %}
|
||||
<a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||
</a>
|
||||
{%- elif href %}
|
||||
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||
</a>
|
||||
@@ -126,6 +135,7 @@
|
||||
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }}
|
||||
{{ mode_item('wifi_locate', 'WF Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg>') }}
|
||||
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||
{{ mode_item('meshcore', 'Meshcore', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><path d="M8 6h8M6 8v8M18 8v8M8 18h8"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,6 +151,7 @@
|
||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mode_item('drone', 'Drone Intel', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><line x1="8" y1="8" x2="9" y2="9"/><line x1="16" y1="8" x2="15" y2="9"/><line x1="8" y1="16" x2="9" y2="15"/><line x1="16" y1="16" x2="15" y2="15"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -261,6 +272,7 @@
|
||||
{{ mobile_item('bt_locate', 'Locate', '<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>') }}
|
||||
{{ mobile_item('wifi_locate', 'WF Loc', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/><circle cx="12" cy="10" r="2"/></svg>') }}
|
||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||
{{ mobile_item('meshcore', 'Meshcore', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><path d="M8 6h8M6 8v8M18 8v8M8 18h8"/></svg>') }}
|
||||
</div>
|
||||
|
||||
{# Intel Group #}
|
||||
@@ -531,12 +543,21 @@
|
||||
window._navClockStarted = true;
|
||||
function updateNavUtcClock() {
|
||||
const now = new Date();
|
||||
const utc = now.toISOString().slice(11, 19);
|
||||
const el = document.getElementById('headerUtcTime');
|
||||
if (el) el.textContent = utc;
|
||||
const label = document.querySelector('.utc-label');
|
||||
if (typeof InterceptTime !== 'undefined') {
|
||||
if (el) el.textContent = InterceptTime.fullTime(now);
|
||||
if (label) label.textContent = InterceptTime.getLabel() || 'LOCAL';
|
||||
} else {
|
||||
if (el) el.textContent = now.toISOString().slice(11, 19);
|
||||
}
|
||||
}
|
||||
setInterval(updateNavUtcClock, 1000);
|
||||
updateNavUtcClock();
|
||||
// React immediately when timezone/format changes in Settings
|
||||
if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
|
||||
InterceptTime.onChange(updateNavUtcClock);
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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">
|
||||
@@ -216,6 +227,36 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Time & Timezone</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Timezone</span>
|
||||
<span class="settings-label-desc">Applied across all modes</span>
|
||||
</div>
|
||||
<select id="globalTimezoneSelect" class="settings-select" onchange="InterceptTime.setTimezone(this.value)">
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="local">Local (browser)</option>
|
||||
<option value="US/Eastern">Eastern (ET)</option>
|
||||
<option value="US/Central">Central (CT)</option>
|
||||
<option value="US/Mountain">Mountain (MT)</option>
|
||||
<option value="US/Pacific">Pacific (PT)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<span class="settings-label-text">Time Format</span>
|
||||
<span class="settings-label-desc">12-hour (2:30 PM) or 24-hour (14:30)</span>
|
||||
</div>
|
||||
<select id="globalTimeFormatSelect" class="settings-select" onchange="InterceptTime.setHour12(this.value === '12')">
|
||||
<option value="12">12-hour (AM/PM)</option>
|
||||
<option value="24">24-hour</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Updates Section -->
|
||||
|
||||
+2925
-373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
# tests/test_drone_correlator.py
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.drone.correlator import DroneCorrelator
|
||||
from utils.drone.models import RemoteIDObservation, RFObservation
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _remote_id_obs(serial="SN001", lat=51.5, lon=-0.1):
|
||||
return RemoteIDObservation(
|
||||
source="WIFI",
|
||||
serial_number=serial,
|
||||
operator_id="OP001",
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
altitude_m=50.0,
|
||||
speed_ms=5.0,
|
||||
heading=90.0,
|
||||
timestamp=_now(),
|
||||
)
|
||||
|
||||
|
||||
def _rf_obs(freq=433_920_000, proto="FRSKY", rssi=-70.0):
|
||||
return RFObservation(
|
||||
frequency_hz=freq,
|
||||
protocol=proto,
|
||||
rssi=rssi,
|
||||
hardware="RTL433",
|
||||
timestamp=_now(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def correlator():
|
||||
q = queue.Queue()
|
||||
return DroneCorrelator(output_queue=q), q
|
||||
|
||||
|
||||
def test_remote_id_creates_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 1
|
||||
assert contacts[0]["compliant"] is True
|
||||
assert contacts[0]["serial_number"] == "SN001"
|
||||
assert contacts[0]["position"] == [51.5, -0.1]
|
||||
|
||||
|
||||
def test_rf_creates_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_rf_obs())
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 1
|
||||
assert contacts[0]["compliant"] is False
|
||||
|
||||
|
||||
def test_remote_id_emits_sse_event(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
msg = q.get_nowait()
|
||||
assert msg["type"] == "contact"
|
||||
assert msg["data"]["serial_number"] == "SN001"
|
||||
|
||||
|
||||
def test_same_serial_updates_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs(lat=51.5, lon=-0.1))
|
||||
corr.process(_remote_id_obs(lat=51.6, lon=-0.2))
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 1
|
||||
assert contacts[0]["position"] == [51.6, -0.2]
|
||||
|
||||
|
||||
def test_different_serials_create_separate_contacts(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs(serial="SN001"))
|
||||
corr.process(_remote_id_obs(serial="SN002"))
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 2
|
||||
|
||||
|
||||
def test_position_history_grows(correlator):
|
||||
corr, q = correlator
|
||||
for i in range(5):
|
||||
corr.process(_remote_id_obs(lat=51.0 + i * 0.01, lon=-0.1))
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts[0]["position_history"]) == 5
|
||||
|
||||
|
||||
def test_position_history_capped_at_500(correlator):
|
||||
corr, q = correlator
|
||||
for i in range(510):
|
||||
corr.process(_remote_id_obs(lat=float(i), lon=0.0))
|
||||
store_values = list(corr._store.values())
|
||||
assert len(store_values[0].position_history) == 500
|
||||
|
||||
|
||||
def test_compliant_single_vector_is_low_risk(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
contacts = corr.get_all()
|
||||
assert contacts[0]["risk_level"] == "low"
|
||||
|
||||
|
||||
def test_non_compliant_is_high_risk(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_rf_obs())
|
||||
contacts = corr.get_all()
|
||||
assert contacts[0]["risk_level"] == "high"
|
||||
|
||||
|
||||
def test_confidence_increases_with_vectors(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
contacts = {c["id"]: c for c in corr.get_all()}
|
||||
rid_contact = next(c for c in contacts.values() if c["compliant"])
|
||||
assert rid_contact["confidence"] == 0.25 # 1/4
|
||||
|
||||
|
||||
def test_ttl_expiry_removes_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
assert len(corr.get_all()) == 1
|
||||
for key in corr._store.timestamps:
|
||||
corr._store.timestamps[key] = time.time() - 300
|
||||
corr._store.cleanup()
|
||||
assert len(corr.get_all()) == 0
|
||||
@@ -0,0 +1,67 @@
|
||||
# tests/test_drone_models.py
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.drone.models import DroneContact, RFSignal
|
||||
from utils.drone.signatures import match_signature
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_drone_contact_to_dict_minimal():
|
||||
c = DroneContact(id="abc123", first_seen=_now(), last_seen=_now())
|
||||
d = c.to_dict()
|
||||
assert d["id"] == "abc123"
|
||||
assert d["compliant"] is False
|
||||
assert d["risk_level"] == "low"
|
||||
assert d["detection_vectors"] == []
|
||||
assert d["position"] is None
|
||||
|
||||
|
||||
def test_drone_contact_to_dict_with_position():
|
||||
c = DroneContact(id="xyz", first_seen=_now(), last_seen=_now())
|
||||
c.position = (51.5, -0.1)
|
||||
c.serial_number = "SN001"
|
||||
c.compliant = True
|
||||
c.detection_vectors = {"REMOTE_ID_WIFI"}
|
||||
d = c.to_dict()
|
||||
assert d["position"] == [51.5, -0.1]
|
||||
assert d["serial_number"] == "SN001"
|
||||
assert d["detection_vectors"] == ["REMOTE_ID_WIFI"]
|
||||
|
||||
|
||||
def test_drone_contact_position_history_capped():
|
||||
c = DroneContact(id="cap", first_seen=_now(), last_seen=_now())
|
||||
for i in range(510):
|
||||
c.position_history.append((float(i), float(i), _now()))
|
||||
d = c.to_dict()
|
||||
# to_dict sends last 50
|
||||
assert len(d["position_history"]) == 50
|
||||
|
||||
|
||||
def test_rf_signal_fields():
|
||||
s = RFSignal(frequency_hz=433_920_000, protocol="FRSKY", rssi=-65.0, hardware="RTL433", timestamp=_now())
|
||||
assert s.frequency_hz == 433_920_000
|
||||
assert s.protocol == "FRSKY"
|
||||
|
||||
|
||||
def test_match_signature_frsky_433():
|
||||
assert match_signature(433_920_000) == "FRSKY"
|
||||
|
||||
|
||||
def test_match_signature_ocusync_24():
|
||||
assert match_signature(2_440_000_000) == "DJI_OCUSYNC"
|
||||
|
||||
|
||||
def test_match_signature_fpv_58():
|
||||
assert match_signature(5_800_000_000) == "FPV_VIDEO"
|
||||
|
||||
|
||||
def test_match_signature_ocusync_at_2450mhz():
|
||||
# 2,450 MHz is within the DJI_OCUSYNC band
|
||||
assert match_signature(2_450_000_000) == "DJI_OCUSYNC"
|
||||
|
||||
|
||||
def test_match_signature_unrecognised():
|
||||
assert match_signature(100_000_000) == "UNKNOWN"
|
||||
@@ -0,0 +1,92 @@
|
||||
# tests/test_drone_remote_id.py
|
||||
import queue
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from utils.drone.remote_id import RemoteIDScanner, _parse_ble_remote_id, _parse_wifi_remote_id
|
||||
|
||||
|
||||
def _make_location_payload(lat=51.5, lon=-0.1, alt=50.0, speed=5.0, heading=90.0) -> bytes:
|
||||
"""Craft a minimal ASTM F3411 Location message (message type 0x01)."""
|
||||
msg_type = 0x01
|
||||
status = 0x00
|
||||
lat_enc = int(lat * 1e7)
|
||||
lon_enc = int(lon * 1e7)
|
||||
alt_enc = int((alt + 1000) / 0.5)
|
||||
speed_enc = int(speed / 0.25)
|
||||
heading_enc = int(heading / 0.01)
|
||||
return struct.pack("<BBiiHBH", msg_type, status, lat_enc, lon_enc, alt_enc, speed_enc, heading_enc)
|
||||
|
||||
|
||||
def _make_basic_id_payload(serial="SN-TESTSERIAL") -> bytes:
|
||||
msg_type = 0x00
|
||||
id_type = 0x01
|
||||
serial_bytes = serial.encode("ascii").ljust(20, b"\x00")[:20]
|
||||
return bytes([msg_type, id_type]) + serial_bytes
|
||||
|
||||
|
||||
def _make_ble_adv_with_remote_id(payload: bytes) -> bytes:
|
||||
uuid_bytes = b"\xfa\xff"
|
||||
service_data_type = 0x16
|
||||
length = len(uuid_bytes) + len(payload) + 1
|
||||
return bytes([length, service_data_type]) + uuid_bytes + payload
|
||||
|
||||
|
||||
def test_parse_ble_location_returns_observation():
|
||||
payload = _make_location_payload(lat=51.5, lon=-0.1, alt=50.0, speed=5.0, heading=90.0)
|
||||
adv = _make_ble_adv_with_remote_id(payload)
|
||||
obs = _parse_ble_remote_id(adv)
|
||||
assert obs is not None
|
||||
assert obs.source == "BLE"
|
||||
assert abs(obs.lat - 51.5) < 0.0001
|
||||
assert abs(obs.lon - (-0.1)) < 0.0001
|
||||
assert abs(obs.altitude_m - 50.0) < 1.0
|
||||
assert abs(obs.speed_ms - 5.0) < 0.5
|
||||
|
||||
|
||||
def test_parse_ble_no_uuid_returns_none():
|
||||
obs = _parse_ble_remote_id(b"\x00\x01\x02\x03")
|
||||
assert obs is None
|
||||
|
||||
|
||||
def test_parse_ble_too_short_returns_none():
|
||||
adv = _make_ble_adv_with_remote_id(b"\x01\x00")
|
||||
obs = _parse_ble_remote_id(adv)
|
||||
assert obs is None
|
||||
|
||||
|
||||
def test_parse_wifi_remote_id_returns_observation():
|
||||
payload = _make_location_payload(lat=52.0, lon=0.5)
|
||||
obs = _parse_wifi_remote_id(payload)
|
||||
assert obs is not None
|
||||
assert obs.source == "WIFI"
|
||||
assert abs(obs.lat - 52.0) < 0.0001
|
||||
|
||||
|
||||
def test_parse_wifi_non_location_returns_none():
|
||||
payload = _make_basic_id_payload()
|
||||
obs = _parse_wifi_remote_id(payload)
|
||||
assert obs is None
|
||||
|
||||
|
||||
def test_scanner_start_stop():
|
||||
q = queue.Queue()
|
||||
scanner = RemoteIDScanner(output_queue=q)
|
||||
with (
|
||||
patch("utils.drone.remote_id.SCAPY_AVAILABLE", True),
|
||||
patch("utils.drone.remote_id.AsyncSniffer") as mock_sniffer,
|
||||
):
|
||||
mock_sniffer.return_value = MagicMock()
|
||||
scanner.start(wifi_iface="wlan0mon")
|
||||
assert scanner.running
|
||||
scanner.stop()
|
||||
assert not scanner.running
|
||||
|
||||
|
||||
def test_scanner_start_without_scapy_still_works():
|
||||
q = queue.Queue()
|
||||
scanner = RemoteIDScanner(output_queue=q)
|
||||
with patch("utils.drone.remote_id.SCAPY_AVAILABLE", False):
|
||||
scanner.start(wifi_iface=None)
|
||||
assert scanner.running
|
||||
scanner.stop()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for RFDetector (rtl_433 + hackrf_sweep control-link detection)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.drone.models import RFObservation
|
||||
from utils.drone.rf_detector import RFDetector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def detector():
|
||||
q = queue.Queue()
|
||||
return RFDetector(output_queue=q), q
|
||||
|
||||
|
||||
def test_detector_not_running_initially(detector):
|
||||
det, q = detector
|
||||
assert not det.running
|
||||
|
||||
|
||||
def test_rtl433_json_line_emits_observation(detector):
|
||||
det, q = detector
|
||||
rtl433_line = json.dumps(
|
||||
{
|
||||
"freq": 433920000,
|
||||
"rssi": -68.5,
|
||||
"protocol": "FrSky",
|
||||
}
|
||||
)
|
||||
det._handle_rtl433_line(rtl433_line)
|
||||
obs = q.get_nowait()
|
||||
assert isinstance(obs, RFObservation)
|
||||
assert obs.frequency_hz == 433_920_000
|
||||
assert obs.hardware == "RTL433"
|
||||
assert obs.rssi == -68.5
|
||||
|
||||
|
||||
def test_rtl433_non_json_line_ignored(detector):
|
||||
det, q = detector
|
||||
det._handle_rtl433_line("not json at all")
|
||||
assert q.empty()
|
||||
|
||||
|
||||
def test_hackrf_sweep_line_emits_observation(detector):
|
||||
det, q = detector
|
||||
# hackrf_sweep CSV: date, time, hz_low, hz_high, hz_bin_width, num_samples, db, db, ...
|
||||
hz_low = 2_440_000_000
|
||||
hz_high = 2_441_000_000
|
||||
sweep_line = f"2026-05-03, 12:00:00, {hz_low}, {hz_high}, 1000000, 10, -45.2, -46.1, -44.8"
|
||||
det._handle_hackrf_line(sweep_line)
|
||||
obs = q.get_nowait()
|
||||
assert isinstance(obs, RFObservation)
|
||||
assert obs.hardware == "HACKRF"
|
||||
assert obs.frequency_hz == (hz_low + hz_high) // 2
|
||||
assert obs.rssi < 0
|
||||
|
||||
|
||||
def test_hackrf_sweep_below_threshold_ignored(detector):
|
||||
det, q = detector
|
||||
hz_low = 2_440_000_000
|
||||
hz_high = 2_441_000_000
|
||||
# Very low power — should be ignored (below -90 dBm threshold)
|
||||
sweep_line = f"2026-05-03, 12:00:00, {hz_low}, {hz_high}, 1000000, 10, -95.0, -96.0, -95.5"
|
||||
det._handle_hackrf_line(sweep_line)
|
||||
assert q.empty()
|
||||
|
||||
|
||||
def test_out_of_band_frequency_ignored(detector):
|
||||
det, q = detector
|
||||
# 915 MHz is not in any drone band
|
||||
line = json.dumps({"freq": 915_000_000, "rssi": -50.0, "protocol": "Generic"})
|
||||
det._handle_rtl433_line(line)
|
||||
assert q.empty()
|
||||
|
||||
|
||||
def test_start_stop(detector):
|
||||
det, q = detector
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdout = MagicMock()
|
||||
mock_proc.stdout.readline = MagicMock(side_effect=[b""])
|
||||
# Patch both shutil.which calls (rtl_433 in _run_rtl433, hackrf_sweep in _run_hackrf)
|
||||
with (
|
||||
patch("subprocess.Popen", return_value=mock_proc),
|
||||
patch("utils.drone.rf_detector.shutil.which", return_value=None),
|
||||
):
|
||||
det.start(rtl_sdr_index=0, use_hackrf=False)
|
||||
assert det.running
|
||||
det.stop()
|
||||
assert not det.running
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user